Browse Source

add endpoint and tests for comments of contents

Guénaël Muller 6 years ago
parent
commit
9baf28fcdd

+ 3 - 0
tracim/__init__.py View File

@@ -21,6 +21,7 @@ from tracim.views.core_api.session_controller import SessionController
21 21
 from tracim.views.core_api.system_controller import SystemController
22 22
 from tracim.views.core_api.user_controller import UserController
23 23
 from tracim.views.core_api.workspace_controller import WorkspaceController
24
+from tracim.views.contents_api.comment_controller import CommentController
24 25
 from tracim.views.errors import ErrorSchema
25 26
 from tracim.lib.utils.cors import add_cors_support
26 27
 
@@ -71,10 +72,12 @@ def web(global_config, **local_settings):
71 72
     system_controller = SystemController()
72 73
     user_controller = UserController()
73 74
     workspace_controller = WorkspaceController()
75
+    comment_controller = CommentController()
74 76
     configurator.include(session_controller.bind, route_prefix=BASE_API_V2)
75 77
     configurator.include(system_controller.bind, route_prefix=BASE_API_V2)
76 78
     configurator.include(user_controller.bind, route_prefix=BASE_API_V2)
77 79
     configurator.include(workspace_controller.bind, route_prefix=BASE_API_V2)
80
+    configurator.include(comment_controller.bind, route_prefix=BASE_API_V2)
78 81
     hapic.add_documentation_view(
79 82
         '/api/v2/doc',
80 83
         'Tracim v2 API',

+ 20 - 1
tracim/fixtures/content.py View File

@@ -38,6 +38,11 @@ class Content(Fixture):
38 38
             session=self._session,
39 39
             config=self._config
40 40
         )
41
+        bob_content_api = ContentApi(
42
+            current_user=bob,
43
+            session=self._session,
44
+            config=self._config
45
+        )
41 46
         role_api = RoleApi(
42 47
             current_user=admin,
43 48
             session=self._session,
@@ -246,5 +251,19 @@ class Content(Fixture):
246 251
             content_api.delete(bad_fruit_salad)
247 252
         content_api.save(bad_fruit_salad)
248 253
 
249
-
254
+        content_api.create_comment(
255
+            parent=best_cake_thread,
256
+            content='<p> What is for you the best cake ever ? </br> I personnally vote for Chocolate cupcake !</p>',  # nopep8
257
+            do_save=True,
258
+        )
259
+        bob_content_api.create_comment(
260
+            parent=best_cake_thread,
261
+            content='<p>What about Apple Pie ? There are Awesome !</p>',
262
+            do_save=True,
263
+        )
264
+        content_api.create_comment(
265
+            parent=best_cake_thread,
266
+            content='<p>You are right, but Kouign-amann are clearly better.</p>',
267
+            do_save=True,
268
+        )
250 269
         self._session.flush()

+ 11 - 4
tracim/lib/core/content.py View File

@@ -418,6 +418,8 @@ class ContentApi(object):
418 418
         item = Content()
419 419
         item.owner = self._user
420 420
         item.parent = parent
421
+        if parent and not workspace:
422
+            workspace = item.parent.workspace
421 423
         item.workspace = workspace
422 424
         item.type = ContentType.Comment
423 425
         item.description = content
@@ -449,15 +451,20 @@ class ContentApi(object):
449 451
 
450 452
         return content
451 453
 
452
-    def get_one(self, content_id: int, content_type: str, workspace: Workspace=None) -> Content:
454
+    def get_one(self, content_id: int, content_type: str, workspace: Workspace=None, parent: Content=None) -> Content:
453 455
 
454 456
         if not content_id:
455 457
             return None
456 458
 
457
-        if content_type==ContentType.Any:
458
-            return self._base_query(workspace).filter(Content.content_id==content_id).one()
459
+        base_request = self._base_query(workspace).filter(Content.content_id==content_id)
459 460
 
460
-        return self._base_query(workspace).filter(Content.content_id==content_id).filter(Content.type==content_type).one()
461
+        if content_type!=ContentType.Any:
462
+            base_request = base_request.filter(Content.type==content_type)
463
+
464
+        if parent:
465
+            base_request = base_request.filter(Content.parent_id==parent.content_id)  # nopep8
466
+
467
+        return base_request.one()
461 468
 
462 469
     def get_one_revision(self, revision_id: int = None) -> ContentRevisionRO:
463 470
         """

+ 29 - 4
tracim/models/contents.py View File

@@ -186,6 +186,33 @@ CONTENT_DEFAULT_TYPE = [
186 186
     folder_type,
187 187
 ]
188 188
 
189
+# TODO - G.M - 31-05-2018 - Set Better Event params
190
+event_type = NewContentType(
191
+    slug='event',
192
+    fa_icon=thread.fa_icon,
193
+    hexcolor=thread.hexcolor,
194
+    label='Event',
195
+    creation_label='Event',
196
+    available_statuses=CONTENT_DEFAULT_STATUS,
197
+)
198
+
199
+# TODO - G.M - 31-05-2018 - Set Better Event params
200
+comment_type = NewContentType(
201
+    slug='comment',
202
+    fa_icon=thread.fa_icon,
203
+    hexcolor=thread.hexcolor,
204
+    label='Comment',
205
+    creation_label='Comment',
206
+    available_statuses=CONTENT_DEFAULT_STATUS,
207
+)
208
+
209
+CONTENT_DEFAULT_TYPE_SPECIAL = [
210
+    event_type,
211
+    comment_type,
212
+]
213
+
214
+ALL_CONTENTS_DEFAULT_TYPES = CONTENT_DEFAULT_TYPE + CONTENT_DEFAULT_TYPE_SPECIAL
215
+
189 216
 
190 217
 class ContentTypeLegacy(NewContentType):
191 218
     """
@@ -204,7 +231,7 @@ class ContentTypeLegacy(NewContentType):
204 231
     MarkdownPage = markdownpluspage_type.slug
205 232
 
206 233
     def __init__(self, slug: str):
207
-        for content_type in CONTENT_DEFAULT_TYPE:
234
+        for content_type in ALL_CONTENTS_DEFAULT_TYPES:
208 235
             if slug == content_type.slug:
209 236
                 super(ContentTypeLegacy, self).__init__(
210 237
                     slug=content_type.slug,
@@ -223,8 +250,7 @@ class ContentTypeLegacy(NewContentType):
223 250
 
224 251
     @classmethod
225 252
     def allowed_types(cls) -> typing.List[str]:
226
-        contents_types = [status.slug for status in CONTENT_DEFAULT_TYPE]
227
-        contents_types.extend([cls.Folder, cls.Event, cls.Comment])
253
+        contents_types = [status.slug for status in ALL_CONTENTS_DEFAULT_TYPES]
228 254
         return contents_types
229 255
 
230 256
     @classmethod
@@ -232,7 +258,6 @@ class ContentTypeLegacy(NewContentType):
232 258
         # This method is used for showing only "main"
233 259
         # types in the left-side treeview
234 260
         contents_types = [status.slug for status in CONTENT_DEFAULT_TYPE]
235
-        contents_types.extend([cls.Folder])
236 261
         return contents_types
237 262
 
238 263
     # TODO - G.M - 30-05-2018 - This method don't do anything.

+ 33 - 0
tracim/models/context_models.py View File

@@ -41,6 +41,16 @@ class WorkspaceAndContentPath(object):
41 41
         self.workspace_id = workspace_id
42 42
 
43 43
 
44
+class CommentPath(object):
45
+    """
46
+    Paths params with workspace id and content_id
47
+    """
48
+    def __init__(self, workspace_id: int, content_id: int, comment_id: int):
49
+        self.content_id = content_id
50
+        self.workspace_id = workspace_id
51
+        self.comment_id = comment_id
52
+
53
+
44 54
 class ContentFilter(object):
45 55
     """
46 56
     Content filter model
@@ -71,6 +81,17 @@ class ContentCreation(object):
71 81
         self.content_type = content_type
72 82
 
73 83
 
84
+class CommentCreation(object):
85
+    """
86
+    Comment creation model
87
+    """
88
+    def __init__(
89
+            self,
90
+            raw_content: str,
91
+    ):
92
+        self.raw_content=raw_content
93
+
94
+
74 95
 class UserInContext(object):
75 96
     """
76 97
     Interface to get User data and User data related to context.
@@ -324,6 +345,18 @@ class ContentInContext(object):
324 345
     def is_deleted(self):
325 346
         return self.content.is_deleted
326 347
 
348
+    @property
349
+    def raw_content(self):
350
+        return self.content.description
351
+
352
+    @property
353
+    def author(self):
354
+        return UserInContext(
355
+            dbsession=self.dbsession,
356
+            config=self.config,
357
+            user=self.content.owner
358
+        )
359
+
327 360
     # Context-related
328 361
 
329 362
     @property

+ 123 - 0
tracim/tests/functional/test_comments.py View File

@@ -0,0 +1,123 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+Tests for /api/v2/workspaces subpath endpoints.
4
+"""
5
+from tracim.tests import FunctionalTest
6
+from tracim.fixtures.content import Content as ContentFixtures
7
+from tracim.fixtures.users_and_groups import Base as BaseFixture
8
+
9
+
10
+class TestCommentsEndpoint(FunctionalTest):
11
+    """
12
+    Tests for /api/v2/workspaces/{workspace_id}/contents/{content_id}/comments
13
+    endpoint
14
+    """
15
+
16
+    fixtures = [BaseFixture, ContentFixtures]
17
+
18
+    def test_api__get_contents_comments__ok_200__nominal_case(self) -> None:
19
+        """
20
+        Get alls comments of a content
21
+        """
22
+        self.testapp.authorization = (
23
+            'Basic',
24
+            (
25
+                'admin@admin.admin',
26
+                'admin@admin.admin'
27
+            )
28
+        )
29
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)   # nopep8
30
+        assert len(res.json_body) == 3
31
+        comment = res.json_body[0]
32
+        assert comment['content_id'] == 18
33
+        assert comment['parent_id'] == 7
34
+        assert comment['raw_content'] == '<p> What is for you the best cake ever ? </br> I personnally vote for Chocolate cupcake !</p>'  # nopep8
35
+        assert comment['author']
36
+        assert comment['author']['user_id'] == 1
37
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
38
+        assert comment['author']['avatar_url'] == None
39
+        assert comment['author']['public_name'] == 'Global manager'
40
+
41
+        comment = res.json_body[1]
42
+        assert comment['content_id'] == 19
43
+        assert comment['parent_id'] == 7
44
+        assert comment['raw_content'] == '<p>What about Apple Pie ? There are Awesome !</p>'  # nopep8
45
+        assert comment['author']
46
+        assert comment['author']['user_id'] == 3
47
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
48
+        assert comment['author']['avatar_url'] == None
49
+        assert comment['author']['public_name'] == 'Bob i.'
50
+
51
+        comment = res.json_body[2]
52
+        assert comment['content_id'] == 20
53
+        assert comment['parent_id'] == 7
54
+        assert comment['raw_content'] == '<p>You are right, but Kouign-amann are clearly better.</p>'  # nopep8
55
+        assert comment['author']
56
+        assert comment['author']['user_id'] == 1
57
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
58
+        assert comment['author']['avatar_url'] == None
59
+        assert comment['author']['public_name'] == 'Global manager'
60
+
61
+    def test_api__post_content_comment__ok_200__nominal_case(self) -> None:
62
+        """
63
+        Get alls comments of a content
64
+        """
65
+        self.testapp.authorization = (
66
+            'Basic',
67
+            (
68
+                'admin@admin.admin',
69
+                'admin@admin.admin'
70
+            )
71
+        )
72
+        params = {
73
+            'raw_content': 'I strongly disagree, Tiramisu win !'
74
+        }
75
+        res = self.testapp.post_json(
76
+            '/api/v2/workspaces/2/contents/7/comments',
77
+            params=params,
78
+            status=200
79
+        )
80
+        comment = res.json_body
81
+        assert comment['content_id']
82
+        assert comment['parent_id'] == 7
83
+        assert comment['raw_content'] == 'I strongly disagree, Tiramisu win !'
84
+        assert comment['author']
85
+        assert comment['author']['user_id'] == 1
86
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
87
+        assert comment['author']['avatar_url'] is None
88
+        assert comment['author']['public_name'] == 'Global manager'
89
+
90
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)  # nopep8
91
+        assert len(res.json_body) == 4
92
+        assert comment == res.json_body[3]
93
+
94
+    def test_api__delete_content_comment__ok_200__nominal_case(self) -> None:
95
+        """
96
+        Get alls comments of a content
97
+        """
98
+        self.testapp.authorization = (
99
+            'Basic',
100
+            (
101
+                'admin@admin.admin',
102
+                'admin@admin.admin'
103
+            )
104
+        )
105
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)
106
+        assert len(res.json_body) == 3
107
+        comment = res.json_body[2]
108
+        assert comment['content_id'] == 20
109
+        assert comment['parent_id'] == 7
110
+        assert comment['raw_content'] == '<p>You are right, but Kouign-amann are clearly better.</p>'   # nopep8
111
+        assert comment['author']
112
+        assert comment['author']['user_id'] == 1
113
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
114
+        assert comment['author']['avatar_url'] is None
115
+        assert comment['author']['public_name'] == 'Global manager'
116
+
117
+        res = self.testapp.delete(
118
+            '/api/v2/workspaces/2/contents/7/comments/20',
119
+            status=204
120
+        )
121
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)
122
+        assert len(res.json_body) == 2
123
+        assert not [content for content in res.json_body if content['content_id'] == 20]  # nopep8

+ 4 - 4
tracim/tests/functional/test_mail_notification.py View File

@@ -162,8 +162,8 @@ class TestNotificationsSync(MailHogTest):
162 162
         assert headers['From'][0] == '"Bob i. via Tracim" <test_user_from+3@localhost>'  # nopep8
163 163
         assert headers['To'][0] == 'Global manager <admin@admin.admin>'
164 164
         assert headers['Subject'][0] == '[TRACIM] [Recipes] file1 (Open)'
165
-        assert headers['References'][0] == 'test_user_refs+19@localhost'
166
-        assert headers['Reply-to'][0] == '"Bob i. & all members of Recipes" <test_user_reply+19@localhost>'  # nopep8
165
+        assert headers['References'][0] == 'test_user_refs+22@localhost'
166
+        assert headers['Reply-to'][0] == '"Bob i. & all members of Recipes" <test_user_reply+22@localhost>'  # nopep8
167 167
 
168 168
 
169 169
 class TestNotificationsAsync(MailHogTest):
@@ -263,5 +263,5 @@ class TestNotificationsAsync(MailHogTest):
263 263
         assert headers['From'][0] == '"Bob i. via Tracim" <test_user_from+3@localhost>'  # nopep8
264 264
         assert headers['To'][0] == 'Global manager <admin@admin.admin>'
265 265
         assert headers['Subject'][0] == '[TRACIM] [Recipes] file1 (Open)'
266
-        assert headers['References'][0] == 'test_user_refs+19@localhost'
267
-        assert headers['Reply-to'][0] == '"Bob i. & all members of Recipes" <test_user_reply+19@localhost>'  # nopep8
266
+        assert headers['References'][0] == 'test_user_refs+22@localhost'
267
+        assert headers['Reply-to'][0] == '"Bob i. & all members of Recipes" <test_user_reply+22@localhost>'  # nopep8

+ 0 - 0
tracim/views/contents_api/__init__.py View File


+ 157 - 0
tracim/views/contents_api/comment_controller.py View File

@@ -0,0 +1,157 @@
1
+# coding=utf-8
2
+import transaction
3
+from pyramid.config import Configurator
4
+try:  # Python 3.5+
5
+    from http import HTTPStatus
6
+except ImportError:
7
+    from http import client as HTTPStatus
8
+
9
+from tracim import TracimRequest
10
+from tracim.extensions import hapic
11
+from tracim.lib.core.content import ContentApi
12
+from tracim.lib.core.workspace import WorkspaceApi
13
+from tracim.views.controllers import Controller
14
+from tracim.views.core_api.schemas import CommentSchema
15
+from tracim.views.core_api.schemas import CommentsPathSchema
16
+from tracim.views.core_api.schemas import SetCommentSchema
17
+from tracim.views.core_api.schemas import WorkspaceAndContentIdPathSchema
18
+from tracim.views.core_api.schemas import NoContentSchema
19
+from tracim.exceptions import WorkspaceNotFound
20
+from tracim.exceptions import InsufficientUserProfile
21
+from tracim.exceptions import NotAuthenticated
22
+from tracim.exceptions import AuthenticationFailed
23
+from tracim.models.contents import ContentTypeLegacy as ContentType
24
+from tracim.models.revision_protection import new_revision
25
+
26
+COMMENT_ENDPOINTS_TAG = 'Comments'
27
+
28
+
29
+class CommentController(Controller):
30
+    pass
31
+
32
+    @hapic.with_api_doc(tags=[COMMENT_ENDPOINTS_TAG])
33
+    @hapic.handle_exception(NotAuthenticated, HTTPStatus.UNAUTHORIZED)
34
+    @hapic.handle_exception(InsufficientUserProfile, HTTPStatus.FORBIDDEN)
35
+    @hapic.handle_exception(WorkspaceNotFound, HTTPStatus.FORBIDDEN)
36
+    @hapic.handle_exception(AuthenticationFailed, HTTPStatus.BAD_REQUEST)
37
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
38
+    @hapic.output_body(CommentSchema(many=True),)
39
+    def content_comments(self, context, request: TracimRequest, hapic_data=None):
40
+        """
41
+        Get all comments related to a content in asc order (first is the oldest)
42
+        """
43
+
44
+        # login = hapic_data.body
45
+        app_config = request.registry.settings['CFG']
46
+        api = ContentApi(
47
+            current_user=request.current_user,
48
+            session=request.dbsession,
49
+            config=app_config,
50
+        )
51
+        content = api.get_one(
52
+            hapic_data.path.content_id,
53
+            content_type=ContentType.Any
54
+        )
55
+        comments = content.get_comments()
56
+        comments.sort(key=lambda comment: comment.created)
57
+        return [api.get_content_in_context(comment)
58
+                for comment in comments
59
+        ]
60
+
61
+    @hapic.with_api_doc(tags=[COMMENT_ENDPOINTS_TAG])
62
+    @hapic.handle_exception(NotAuthenticated, HTTPStatus.UNAUTHORIZED)
63
+    @hapic.handle_exception(InsufficientUserProfile, HTTPStatus.FORBIDDEN)
64
+    @hapic.handle_exception(WorkspaceNotFound, HTTPStatus.FORBIDDEN)
65
+    @hapic.handle_exception(AuthenticationFailed, HTTPStatus.BAD_REQUEST)
66
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
67
+    @hapic.input_body(SetCommentSchema())
68
+    @hapic.output_body(CommentSchema(),)
69
+    def add_comment(self, context, request: TracimRequest, hapic_data=None):
70
+        """
71
+        Add new comment
72
+        """
73
+        # login = hapic_data.body
74
+        app_config = request.registry.settings['CFG']
75
+        api = ContentApi(
76
+            current_user=request.current_user,
77
+            session=request.dbsession,
78
+            config=app_config,
79
+        )
80
+        content = api.get_one(
81
+            hapic_data.path.content_id,
82
+            content_type=ContentType.Any
83
+        )
84
+        comment = api.create_comment(
85
+            content.workspace,
86
+            content,
87
+            hapic_data.body.raw_content,
88
+            do_save=True,
89
+        )
90
+        return api.get_content_in_context(comment)
91
+
92
+    @hapic.with_api_doc(tags=[COMMENT_ENDPOINTS_TAG])
93
+    @hapic.handle_exception(NotAuthenticated, HTTPStatus.UNAUTHORIZED)
94
+    @hapic.handle_exception(InsufficientUserProfile, HTTPStatus.FORBIDDEN)
95
+    @hapic.handle_exception(WorkspaceNotFound, HTTPStatus.FORBIDDEN)
96
+    @hapic.handle_exception(AuthenticationFailed, HTTPStatus.BAD_REQUEST)
97
+    @hapic.input_path(CommentsPathSchema())
98
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
99
+    def delete_comment(self, context, request: TracimRequest, hapic_data=None):
100
+        """
101
+        Delete comment
102
+        """
103
+        app_config = request.registry.settings['CFG']
104
+        api = ContentApi(
105
+            current_user=request.current_user,
106
+            session=request.dbsession,
107
+            config=app_config,
108
+        )
109
+        wapi = WorkspaceApi(
110
+            current_user=request.current_user,
111
+            session=request.dbsession,
112
+            config=app_config,
113
+        )
114
+        workspace = wapi.get_one(hapic_data.path.workspace_id)
115
+        parent = api.get_one(
116
+            hapic_data.path.content_id,
117
+            content_type=ContentType.Any,
118
+            workspace=workspace
119
+        )
120
+        comment = api.get_one(
121
+            hapic_data.path.comment_id,
122
+            content_type=ContentType.Comment,
123
+            workspace=workspace,
124
+            parent=parent,
125
+        )
126
+        with new_revision(
127
+                session=request.dbsession,
128
+                tm=transaction.manager,
129
+                content=comment
130
+        ):
131
+            api.delete(comment)
132
+        return
133
+
134
+    def bind(self, configurator: Configurator):
135
+        # Get comments
136
+        configurator.add_route(
137
+            'content_comments',
138
+            '/workspaces/{workspace_id}/contents/{content_id}/comments',
139
+            request_method='GET'
140
+        )
141
+        configurator.add_view(self.content_comments, route_name='content_comments')
142
+
143
+        # Add comments
144
+        configurator.add_route(
145
+            'add_comment',
146
+            '/workspaces/{workspace_id}/contents/{content_id}/comments',
147
+            request_method='POST'
148
+        )  # nopep8
149
+        configurator.add_view(self.add_comment, route_name='add_comment')
150
+
151
+        # delete comments
152
+        configurator.add_route(
153
+            'delete_comment',
154
+            '/workspaces/{workspace_id}/contents/{content_id}/comments/{comment_id}',  # nopep8
155
+            request_method='DELETE'
156
+        )
157
+        configurator.add_view(self.delete_comment, route_name='delete_comment')

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

@@ -9,6 +9,8 @@ from tracim.models.contents import CONTENT_DEFAULT_STATUS
9 9
 from tracim.models.contents import GlobalStatus
10 10
 from tracim.models.contents import open_status
11 11
 from tracim.models.context_models import ContentCreation
12
+from tracim.models.context_models import CommentCreation
13
+from tracim.models.context_models import CommentPath
12 14
 from tracim.models.context_models import MoveParams
13 15
 from tracim.models.context_models import WorkspaceAndContentPath
14 16
 from tracim.models.context_models import ContentFilter
@@ -100,6 +102,9 @@ class CommentsPathSchema(WorkspaceAndContentIdPathSchema):
100 102
         description='id of a comment related to content content_id',
101 103
         required=True
102 104
     )
105
+    @post_load
106
+    def make_path_object(self, data):
107
+        return CommentPath(**data)
103 108
 
104 109
 
105 110
 class FilterContentQuerySchema(marshmallow.Schema):
@@ -455,6 +460,10 @@ class SetCommentSchema(marshmallow.Schema):
455 460
         example='<p>This is just an html comment !</p>'
456 461
     )
457 462
 
463
+    @post_load
464
+    def create_comment(self, data):
465
+        return CommentCreation(**data)
466
+
458 467
 
459 468
 class SetContentStatusSchema(marshmallow.Schema):
460 469
     status = marshmallow.fields.Str(
@@ -462,4 +471,4 @@ class SetContentStatusSchema(marshmallow.Schema):
462 471
         validate=OneOf([status.slug for status in CONTENT_DEFAULT_STATUS]),
463 472
         description='this slug is found in content_type available statuses',
464 473
         default=open_status
465
-    )
474
+    )