Przeglądaj źródła

Add put and post for workspace endpoints #587

Guénaël Muller 6 lat temu
rodzic
commit
560af8e809

+ 37 - 6
tracim/models/context_models.py Wyświetl plik

13
 from tracim.models.workspace_menu_entries import WorkspaceMenuEntry
13
 from tracim.models.workspace_menu_entries import WorkspaceMenuEntry
14
 
14
 
15
 
15
 
16
+class MoveParams(object):
17
+    """
18
+    Json body params for move action
19
+    """
20
+    def __init__(self, new_parent_id: str):
21
+        self.new_parent_id = new_parent_id
22
+
23
+
16
 class LoginCredentials(object):
24
 class LoginCredentials(object):
17
     """
25
     """
18
     Login credentials model for login
26
     Login credentials model for login
23
         self.password = password
31
         self.password = password
24
 
32
 
25
 
33
 
34
+class WorkspaceAndContentPath(object):
35
+    """
36
+    Paths params with workspace id and content_id
37
+    """
38
+    def __init__(self, workspace_id: int, content_id: int):
39
+        self.content_id = content_id
40
+        self.workspace_id = workspace_id
41
+
42
+
26
 class ContentFilter(object):
43
 class ContentFilter(object):
27
     """
44
     """
28
     Content filter model
45
     Content filter model
29
     """
46
     """
30
-    def __init__(self,
31
-                 parent_id: int = None,
32
-                 show_archived: int = 0,
33
-                 show_deleted: int = 0,
34
-                 show_active: int = 1,
35
-                 ):
47
+    def __init__(
48
+            self,
49
+            parent_id: int = None,
50
+            show_archived: int = 0,
51
+            show_deleted: int = 0,
52
+            show_active: int = 1,
53
+    ):
36
         self.parent_id = parent_id
54
         self.parent_id = parent_id
37
         self.show_archived = bool(show_archived)
55
         self.show_archived = bool(show_archived)
38
         self.show_deleted = bool(show_deleted)
56
         self.show_deleted = bool(show_deleted)
39
         self.show_active = bool(show_active)
57
         self.show_active = bool(show_active)
40
 
58
 
41
 
59
 
60
+class ContentCreation(object):
61
+    """
62
+    Content creation model
63
+    """
64
+    def __init__(
65
+            self,
66
+            label: str,
67
+            content_type_slug: str,
68
+    ):
69
+        self.label = label
70
+        self.content_type_slug = content_type_slug
71
+
72
+
42
 class UserInContext(object):
73
 class UserInContext(object):
43
     """
74
     """
44
     Interface to get User data and User data related to context.
75
     Interface to get User data and User data related to context.

+ 133 - 0
tracim/tests/functional/test_workspaces.py Wyświetl plik

727
         assert 'code' in res.json.keys()
727
         assert 'code' in res.json.keys()
728
         assert 'message' in res.json.keys()
728
         assert 'message' in res.json.keys()
729
         assert 'details' in res.json.keys()
729
         assert 'details' in res.json.keys()
730
+
731
+    def test_api__post_content_create_generic_content__ok_200__nominal_case(self) -> None:
732
+        """
733
+        Create generic content
734
+        """
735
+        self.testapp.authorization = (
736
+            'Basic',
737
+            (
738
+                'admin@admin.admin',
739
+                'admin@admin.admin'
740
+            )
741
+        )
742
+        params = {
743
+            'label': 'GenericCreatedContent',
744
+            'content_type_slug': 'markdownpage',
745
+        }
746
+        res = self.testapp.post_json(
747
+            '/api/v2/workspaces/1/contents',
748
+            params=params,
749
+            status=200
750
+        )
751
+        assert res
752
+        assert res.json_body
753
+        assert res.json_body['status_slug'] == 'open'
754
+        assert res.json_body['id']
755
+        assert res.json_body['content_type_slug'] == 'markdownpage'
756
+        assert res.json_body['is_archived'] is False
757
+        assert res.json_body['is_deleted'] is False
758
+        assert res.json_body['workspace_id'] == 1
759
+        assert res.json_body['slug'] == 'genericcreatedcontent'
760
+        assert res.json_body['parent_id'] is None
761
+        assert res.json_body['show_in_ui'] is True
762
+        assert res.json_body['sub_content_type_slug']
763
+
764
+    def test_api_put_move_content__ok_200__nominal_case(self):
765
+        """
766
+        Move content
767
+        """
768
+        self.testapp.authorization = (
769
+            'Basic',
770
+            (
771
+                'admin@admin.admin',
772
+                'admin@admin.admin'
773
+            )
774
+        )
775
+        params = {
776
+            'new_parent_id': '4',  # Salads
777
+        }
778
+        # TODO - G.M - 2018-06-163 - Check content
779
+        res = self.testapp.put_json(
780
+            # INFO - G.M - 2018-06-163 - move Apple_Pie
781
+            # from Desserts to Salads subfolder of workspace Recipes
782
+            '/api/v2/workspaces/2/contents/8/move',
783
+            params=params,
784
+            status=200
785
+        )
786
+        # TODO - G.M - 2018-06-163 - Recheck content
787
+
788
+    def test_api_put_delete_content__ok_200__nominal_case(self):
789
+        """
790
+        Move content
791
+        """
792
+        self.testapp.authorization = (
793
+            'Basic',
794
+            (
795
+                'admin@admin.admin',
796
+                'admin@admin.admin'
797
+            )
798
+        )
799
+        # TODO - G.M - 2018-06-163 - Check content
800
+        res = self.testapp.put_json(
801
+            # INFO - G.M - 2018-06-163 - delete Apple_Pie
802
+            '/api/v2/workspaces/2/contents/8/delete',
803
+            status=200
804
+        )
805
+        # TODO - G.M - 2018-06-163 - Recheck content
806
+
807
+    def test_api_put_archive_content__ok_200__nominal_case(self):
808
+        """
809
+        archive content
810
+        """
811
+        self.testapp.authorization = (
812
+            'Basic',
813
+            (
814
+                'admin@admin.admin',
815
+                'admin@admin.admin'
816
+            )
817
+        )
818
+        # TODO - G.M - 2018-06-163 - Check content
819
+        res = self.testapp.put_json(
820
+            # INFO - G.M - 2018-06-163 - archive Apple_Pie
821
+            '/api/v2/workspaces/2/contents/8/archive',
822
+            status=200
823
+        )
824
+        # TODO - G.M - 2018-06-163 - Recheck content
825
+
826
+    def test_api_put_undelete_content__ok_200__nominal_case(self):
827
+        """
828
+        Delete content
829
+        """
830
+        self.testapp.authorization = (
831
+            'Basic',
832
+            (
833
+                'bob@fsf.local',
834
+                'foobarbaz'
835
+            )
836
+        )
837
+        # TODO - G.M - 2018-06-163 - Check content
838
+        res = self.testapp.put_json(
839
+            # INFO - G.M - 2018-06-163 - delete Apple_Pie
840
+            '/api/v2/workspaces/2/contents/14/undelete',
841
+            status=200
842
+        )
843
+        # TODO - G.M - 2018-06-163 - Recheck content
844
+
845
+    def test_api_put_unarchive_content__ok_200__nominal_case(self):
846
+        """
847
+        Delete content
848
+        """
849
+        self.testapp.authorization = (
850
+            'Basic',
851
+            (
852
+                'bob@fsf.local',
853
+                'foobarbaz'
854
+            )
855
+        )
856
+        # TODO - G.M - 2018-06-163 - Check content
857
+        res = self.testapp.put_json(
858
+            # INFO - G.M - 2018-06-163 - delete Apple_Pie
859
+            '/api/v2/workspaces/2/contents/13/unarchive',
860
+            status=200
861
+        )
862
+        # TODO - G.M - 2018-06-163 - Recheck content

+ 24 - 6
tracim/views/core_api/schemas.py Wyświetl plik

4
 from marshmallow.validate import OneOf
4
 from marshmallow.validate import OneOf
5
 
5
 
6
 from tracim.models.auth import Profile
6
 from tracim.models.auth import Profile
7
-from tracim.models.contents import CONTENT_DEFAULT_TYPE, GlobalStatus, CONTENT_DEFAULT_STATUS
8
-from tracim.models.context_models import LoginCredentials, ContentFilter
7
+from tracim.models.contents import CONTENT_DEFAULT_TYPE
8
+from tracim.models.contents import CONTENT_DEFAULT_STATUS
9
+from tracim.models.contents import GlobalStatus
10
+from tracim.models.contents import open_status
11
+from tracim.models.context_models import ContentCreation
12
+from tracim.models.context_models import MoveParams
13
+from tracim.models.context_models import WorkspaceAndContentPath
14
+from tracim.models.context_models import ContentFilter
15
+from tracim.models.context_models import LoginCredentials
9
 from tracim.models.data import UserRoleInWorkspace
16
 from tracim.models.data import UserRoleInWorkspace
10
 
17
 
11
 
18
 
82
 
89
 
83
 
90
 
84
 class WorkspaceAndContentIdPathSchema(WorkspaceIdPathSchema, ContentIdPathSchema):
91
 class WorkspaceAndContentIdPathSchema(WorkspaceIdPathSchema, ContentIdPathSchema):
85
-    pass
92
+    @post_load
93
+    def make_path_object(self, data):
94
+        return WorkspaceAndContentPath(**data)
86
 
95
 
87
 
96
 
88
 class FilterContentQuerySchema(marshmallow.Schema):
97
 class FilterContentQuerySchema(marshmallow.Schema):
292
         description='id of the new parent content id.'
301
         description='id of the new parent content id.'
293
     )
302
     )
294
 
303
 
304
+    @post_load
305
+    def make_move_params(self, data):
306
+        return MoveParams(**data)
307
+
295
 
308
 
296
 class ContentCreationSchema(marshmallow.Schema):
309
 class ContentCreationSchema(marshmallow.Schema):
297
     label = marshmallow.fields.String(
310
     label = marshmallow.fields.String(
300
     )
313
     )
301
     content_type_slug = marshmallow.fields.String(
314
     content_type_slug = marshmallow.fields.String(
302
         example='htmlpage',
315
         example='htmlpage',
303
-        validate=OneOf(CONTENT_DEFAULT_TYPE),
316
+        validate=OneOf([content.slug for content in CONTENT_DEFAULT_TYPE]),
304
     )
317
     )
305
 
318
 
319
+    @post_load
320
+    def make_content_filter(self, data):
321
+        return ContentCreation(**data)
322
+
306
 
323
 
307
 class ContentDigestSchema(marshmallow.Schema):
324
 class ContentDigestSchema(marshmallow.Schema):
308
     id = marshmallow.fields.Int(example=6)
325
     id = marshmallow.fields.Int(example=6)
330
         example='closed-deprecated',
347
         example='closed-deprecated',
331
         validate=OneOf([status.slug for status in CONTENT_DEFAULT_STATUS]),
348
         validate=OneOf([status.slug for status in CONTENT_DEFAULT_STATUS]),
332
         description='this slug is found in content_type available statuses',
349
         description='this slug is found in content_type available statuses',
350
+        default=open_status
333
     )
351
     )
334
-    is_archived = marshmallow.fields.Bool(example=False)
335
-    is_deleted = marshmallow.fields.Bool(example=False)
352
+    is_archived = marshmallow.fields.Bool(example=False, default=False)
353
+    is_deleted = marshmallow.fields.Bool(example=False, default=False)
336
     show_in_ui = marshmallow.fields.Bool(
354
     show_in_ui = marshmallow.fields.Bool(
337
         example=True,
355
         example=True,
338
         description='if false, then do not show content in the treeview. '
356
         description='if false, then do not show content in the treeview. '

+ 237 - 3
tracim/views/core_api/workspace_controller.py Wyświetl plik

1
 import typing
1
 import typing
2
 
2
 
3
+import transaction
3
 from pyramid.config import Configurator
4
 from pyramid.config import Configurator
4
-
5
 try:  # Python 3.5+
5
 try:  # Python 3.5+
6
     from http import HTTPStatus
6
     from http import HTTPStatus
7
 except ImportError:
7
 except ImportError:
12
 from tracim.lib.core.content import ContentApi
12
 from tracim.lib.core.content import ContentApi
13
 from tracim.lib.core.userworkspace import RoleApi
13
 from tracim.lib.core.userworkspace import RoleApi
14
 from tracim.lib.utils.authorization import require_workspace_role
14
 from tracim.lib.utils.authorization import require_workspace_role
15
-from tracim.models.data import UserRoleInWorkspace
15
+from tracim.models.data import UserRoleInWorkspace, ActionDescription
16
 from tracim.models.context_models import UserRoleWorkspaceInContext
16
 from tracim.models.context_models import UserRoleWorkspaceInContext
17
 from tracim.models.context_models import ContentInContext
17
 from tracim.models.context_models import ContentInContext
18
 from tracim.exceptions import NotAuthentificated
18
 from tracim.exceptions import NotAuthentificated
20
 from tracim.exceptions import WorkspaceNotFound
20
 from tracim.exceptions import WorkspaceNotFound
21
 from tracim.views.controllers import Controller
21
 from tracim.views.controllers import Controller
22
 from tracim.views.core_api.schemas import FilterContentQuerySchema
22
 from tracim.views.core_api.schemas import FilterContentQuerySchema
23
+from tracim.views.core_api.schemas import ContentMoveSchema
24
+from tracim.views.core_api.schemas import NoContentSchema
25
+from tracim.views.core_api.schemas import ContentCreationSchema
26
+from tracim.views.core_api.schemas import WorkspaceAndContentIdPathSchema
23
 from tracim.views.core_api.schemas import ContentDigestSchema
27
 from tracim.views.core_api.schemas import ContentDigestSchema
24
 from tracim.views.core_api.schemas import WorkspaceSchema
28
 from tracim.views.core_api.schemas import WorkspaceSchema
25
 from tracim.views.core_api.schemas import WorkspaceIdPathSchema
29
 from tracim.views.core_api.schemas import WorkspaceIdPathSchema
26
 from tracim.views.core_api.schemas import WorkspaceMemberSchema
30
 from tracim.views.core_api.schemas import WorkspaceMemberSchema
31
+from tracim.models.data import ContentType
32
+from tracim.models.revision_protection import new_revision
27
 
33
 
28
 
34
 
29
 class WorkspaceController(Controller):
35
 class WorkspaceController(Controller):
113
         ]
119
         ]
114
         return contents
120
         return contents
115
 
121
 
122
+    @hapic.with_api_doc()
123
+    @hapic.handle_exception(NotAuthentificated, HTTPStatus.UNAUTHORIZED)
124
+    @hapic.handle_exception(InsufficientUserProfile, HTTPStatus.FORBIDDEN)
125
+    @hapic.handle_exception(WorkspaceNotFound, HTTPStatus.FORBIDDEN)
126
+    @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
127
+    @hapic.input_path(WorkspaceIdPathSchema())
128
+    @hapic.input_body(ContentCreationSchema())
129
+    @hapic.output_body(ContentDigestSchema())
130
+    def create_generic_empty_content(
131
+            self,
132
+            context,
133
+            request: TracimRequest,
134
+            hapic_data=None,
135
+    ) -> typing.List[ContentInContext]:
136
+        """
137
+        create a generic empty content
138
+        """
139
+        app_config = request.registry.settings['CFG']
140
+        creation_data = hapic_data.body
141
+        api = ContentApi(
142
+            current_user=request.current_user,
143
+            session=request.dbsession,
144
+            config=app_config,
145
+        )
146
+        content = api.create(
147
+            label=creation_data.label,
148
+            content_type=creation_data.content_type_slug,
149
+            workspace=request.current_workspace,
150
+        )
151
+        api.save(content, ActionDescription.CREATION)
152
+        content = api.get_content_in_context(content)
153
+        return content
154
+
155
+    @hapic.with_api_doc()
156
+    @hapic.handle_exception(NotAuthentificated, HTTPStatus.UNAUTHORIZED)
157
+    @hapic.handle_exception(InsufficientUserProfile, HTTPStatus.FORBIDDEN)
158
+    @hapic.handle_exception(WorkspaceNotFound, HTTPStatus.FORBIDDEN)
159
+    @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
160
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
161
+    @hapic.input_body(ContentMoveSchema())
162
+    @hapic.output_body(NoContentSchema())
163
+    def move_content(
164
+            self,
165
+            context,
166
+            request: TracimRequest,
167
+            hapic_data=None,
168
+    ) -> typing.List[ContentInContext]:
169
+        """
170
+        move a content
171
+        """
172
+        app_config = request.registry.settings['CFG']
173
+        path_data = hapic_data.path
174
+        move_data = hapic_data.body
175
+        api = ContentApi(
176
+            current_user=request.current_user,
177
+            session=request.dbsession,
178
+            config=app_config,
179
+        )
180
+        content = api.get_one(
181
+            path_data.content_id,
182
+            content_type=ContentType.Any
183
+        )
184
+        new_parent = api.get_one(
185
+            move_data.new_parent_id, content_type=ContentType.Any
186
+        )
187
+        with new_revision(
188
+                session=request.dbsession,
189
+                tm=transaction.manager,
190
+                content=content
191
+        ):
192
+            api.move(content, new_parent=new_parent)
193
+        return
194
+
195
+    @hapic.with_api_doc()
196
+    @hapic.handle_exception(NotAuthentificated, HTTPStatus.UNAUTHORIZED)
197
+    @hapic.handle_exception(InsufficientUserProfile, HTTPStatus.FORBIDDEN)
198
+    @hapic.handle_exception(WorkspaceNotFound, HTTPStatus.FORBIDDEN)
199
+    @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
200
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
201
+    @hapic.output_body(NoContentSchema())
202
+    def delete_content(
203
+            self,
204
+            context,
205
+            request: TracimRequest,
206
+            hapic_data=None,
207
+    ) -> typing.List[ContentInContext]:
208
+        """
209
+        delete a content
210
+        """
211
+        app_config = request.registry.settings['CFG']
212
+        path_data = hapic_data.path
213
+        api = ContentApi(
214
+            current_user=request.current_user,
215
+            session=request.dbsession,
216
+            config=app_config,
217
+        )
218
+        content = api.get_one(
219
+            path_data.content_id,
220
+            content_type=ContentType.Any
221
+        )
222
+        with new_revision(
223
+                session=request.dbsession,
224
+                tm=transaction.manager,
225
+                content=content
226
+        ):
227
+            api.delete(content)
228
+        return
229
+
230
+    @hapic.with_api_doc()
231
+    @hapic.handle_exception(NotAuthentificated, HTTPStatus.UNAUTHORIZED)
232
+    @hapic.handle_exception(InsufficientUserProfile, HTTPStatus.FORBIDDEN)
233
+    @hapic.handle_exception(WorkspaceNotFound, HTTPStatus.FORBIDDEN)
234
+    @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
235
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
236
+    @hapic.output_body(NoContentSchema())
237
+    def undelete_content(
238
+            self,
239
+            context,
240
+            request: TracimRequest,
241
+            hapic_data=None,
242
+    ) -> typing.List[ContentInContext]:
243
+        """
244
+        undelete a content
245
+        """
246
+        app_config = request.registry.settings['CFG']
247
+        path_data = hapic_data.path
248
+        api = ContentApi(
249
+            current_user=request.current_user,
250
+            session=request.dbsession,
251
+            config=app_config,
252
+            show_deleted=True,
253
+        )
254
+        content = api.get_one(
255
+            path_data.content_id,
256
+            content_type=ContentType.Any
257
+        )
258
+        with new_revision(
259
+                session=request.dbsession,
260
+                tm=transaction.manager,
261
+                content=content
262
+        ):
263
+            api.undelete(content)
264
+        return
265
+
266
+    @hapic.with_api_doc()
267
+    @hapic.handle_exception(NotAuthentificated, HTTPStatus.UNAUTHORIZED)
268
+    @hapic.handle_exception(InsufficientUserProfile, HTTPStatus.FORBIDDEN)
269
+    @hapic.handle_exception(WorkspaceNotFound, HTTPStatus.FORBIDDEN)
270
+    @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
271
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
272
+    @hapic.output_body(NoContentSchema())
273
+    def archive_content(
274
+            self,
275
+            context,
276
+            request: TracimRequest,
277
+            hapic_data=None,
278
+    ) -> typing.List[ContentInContext]:
279
+        """
280
+        archive a content
281
+        """
282
+        app_config = request.registry.settings['CFG']
283
+        path_data = hapic_data.path
284
+        api = ContentApi(
285
+            current_user=request.current_user,
286
+            session=request.dbsession,
287
+            config=app_config,
288
+        )
289
+        content = api.get_one(path_data.content_id, content_type=ContentType.Any)
290
+        with new_revision(
291
+                session=request.dbsession,
292
+                tm=transaction.manager,
293
+                content=content
294
+        ):
295
+            api.archive(content)
296
+        return
297
+
298
+    @hapic.with_api_doc()
299
+    @hapic.handle_exception(NotAuthentificated, HTTPStatus.UNAUTHORIZED)
300
+    @hapic.handle_exception(InsufficientUserProfile, HTTPStatus.FORBIDDEN)
301
+    @hapic.handle_exception(WorkspaceNotFound, HTTPStatus.FORBIDDEN)
302
+    @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
303
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
304
+    @hapic.output_body(NoContentSchema())
305
+    def unarchive_content(
306
+            self,
307
+            context,
308
+            request: TracimRequest,
309
+            hapic_data=None,
310
+    ) -> typing.List[ContentInContext]:
311
+        """
312
+        unarchive a content
313
+        """
314
+        app_config = request.registry.settings['CFG']
315
+        path_data = hapic_data.path
316
+        api = ContentApi(
317
+            current_user=request.current_user,
318
+            session=request.dbsession,
319
+            config=app_config,
320
+            show_archived=True,
321
+        )
322
+        content = api.get_one(
323
+            path_data.content_id,
324
+            content_type=ContentType.Any
325
+        )
326
+        with new_revision(
327
+                session=request.dbsession,
328
+                tm=transaction.manager,
329
+                content=content
330
+        ):
331
+            api.unarchive(content)
332
+        return
333
+
116
     def bind(self, configurator: Configurator) -> None:
334
     def bind(self, configurator: Configurator) -> None:
117
         """
335
         """
118
         Create all routes and views using
336
         Create all routes and views using
127
         configurator.add_view(self.workspaces_members, route_name='workspace_members')  # nopep8
345
         configurator.add_view(self.workspaces_members, route_name='workspace_members')  # nopep8
128
         # Workspace Content
346
         # Workspace Content
129
         configurator.add_route('workspace_content', '/workspaces/{workspace_id}/contents', request_method='GET')  # nopep8
347
         configurator.add_route('workspace_content', '/workspaces/{workspace_id}/contents', request_method='GET')  # nopep8
130
-        configurator.add_view(self.workspace_content, route_name='workspace_content')  # nopep8
348
+        configurator.add_view(self.workspace_content, route_name='workspace_content')  # nopep8
349
+        # Create Generic Content
350
+        configurator.add_route('create_generic_content', '/workspaces/{workspace_id}/contents', request_method='POST')  # nopep8
351
+        configurator.add_view(self.create_generic_empty_content, route_name='create_generic_content')  # nopep8
352
+        # Move Content
353
+        configurator.add_route('move_content', '/workspaces/{workspace_id}/contents/{content_id}/move', request_method='PUT')  # nopep8
354
+        configurator.add_view(self.move_content, route_name='move_content')  # nopep8
355
+        # Delete/Undelete Content
356
+        configurator.add_route('delete_content', '/workspaces/{workspace_id}/contents/{content_id}/delete', request_method='PUT')  # nopep8
357
+        configurator.add_view(self.delete_content, route_name='delete_content')  # nopep8
358
+        configurator.add_route('undelete_content', '/workspaces/{workspace_id}/contents/{content_id}/undelete', request_method='PUT')  # nopep8
359
+        configurator.add_view(self.undelete_content, route_name='undelete_content')  # nopep8
360
+        # # Archive/Unarchive Content
361
+        configurator.add_route('archive_content', '/workspaces/{workspace_id}/contents/{content_id}/archive', request_method='PUT')  # nopep8
362
+        configurator.add_view(self.archive_content, route_name='archive_content')  # nopep8
363
+        configurator.add_route('unarchive_content', '/workspaces/{workspace_id}/contents/{content_id}/unarchive', request_method='PUT')  # nopep8
364
+        configurator.add_view(self.unarchive_content, route_name='unarchive_content')  # nopep8