Browse Source

merge with some fix for folder controller

Guénaël Muller 5 years ago
parent
commit
3bcb0b91a8
100 changed files with 4678 additions and 674 deletions
  1. 1 0
      .travis.yml
  2. 56 7
      backend/README.md
  3. 23 0
      backend/doc/known_issues.md
  4. 4 0
      backend/doc/migration.md
  5. 4 2
      backend/doc/roles.md
  6. 4 0
      backend/tracim_backend/__init__.py
  7. 2 0
      backend/tracim_backend/app_models/applications.py
  8. 31 7
      backend/tracim_backend/app_models/contents.py
  9. 5 4
      backend/tracim_backend/command/user.py
  10. 1 0
      backend/tracim_backend/config.py
  11. 8 0
      backend/tracim_backend/exceptions.py
  12. 78 15
      backend/tracim_backend/lib/core/content.py
  13. 46 9
      backend/tracim_backend/lib/core/user.py
  14. 13 12
      backend/tracim_backend/lib/core/workspace.py
  15. 34 0
      backend/tracim_backend/lib/utils/authorization.py
  16. 9 3
      backend/tracim_backend/lib/utils/request.py
  17. 0 8
      backend/tracim_backend/lib/webdav/resources.py
  18. 26 0
      backend/tracim_backend/migration/versions/78b52ca39419_add_is_deleted_to_user.py
  19. 2 0
      backend/tracim_backend/models/auth.py
  20. 50 20
      backend/tracim_backend/models/context_models.py
  21. 23 42
      backend/tracim_backend/models/data.py
  22. 809 0
      backend/tracim_backend/tests/functional/test_contents.py
  23. 410 1
      backend/tracim_backend/tests/functional/test_user.py
  24. 816 22
      backend/tracim_backend/tests/functional/test_workspaces.py
  25. 401 0
      backend/tracim_backend/tests/library/test_content_api.py
  26. 198 0
      backend/tracim_backend/views/contents_api/folder_controller.py
  27. 57 6
      backend/tracim_backend/views/core_api/schemas.py
  28. 47 1
      backend/tracim_backend/views/core_api/user_controller.py
  29. 147 1
      backend/tracim_backend/views/core_api/workspace_controller.py
  30. 52 0
      backend_lib.sh
  31. 34 2
      frontend/src/action-creator.async.js
  32. 8 3
      frontend/src/action-creator.sync.js
  33. 3 3
      frontend/src/component/Account/Password.jsx
  34. 3 3
      frontend/src/component/Account/PersonalData.jsx
  35. 2 2
      frontend/src/component/Account/UserInfo.jsx
  36. 3 1
      frontend/src/component/Dashboard/ContentTypeBtn.jsx
  37. 1 5
      frontend/src/component/Dashboard/ContentTypeBtn.styl
  38. 4 3
      frontend/src/component/Dashboard/MemberList.jsx
  39. 18 12
      frontend/src/component/Dashboard/MemberList.styl
  40. 2 2
      frontend/src/component/Dashboard/MoreInfo.jsx
  41. 1 0
      frontend/src/component/Dashboard/MoreInfo.styl
  42. 9 4
      frontend/src/component/Dashboard/RecentActivity.jsx
  43. 22 6
      frontend/src/component/Dashboard/RecentActivity.styl
  44. 2 2
      frontend/src/component/Dashboard/UserStatus.jsx
  45. 41 2
      frontend/src/component/Dashboard/UserStatus.styl
  46. 29 0
      frontend/src/component/Header/MenuActionListItem/AdminLink.jsx
  47. 26 23
      frontend/src/component/Header/MenuActionListItem/MenuProfil.jsx
  48. 1 1
      frontend/src/component/Header/MenuActionListItem/Search.jsx
  49. 8 8
      frontend/src/component/Sidebar/WorkspaceListItem.jsx
  50. 1 1
      frontend/src/component/common/Input/SubDropdownCreateButton.jsx
  51. 1 1
      frontend/src/container/Account.jsx
  52. 128 71
      frontend/src/container/AdminWorkspacePage.jsx
  53. 9 5
      frontend/src/container/AppFullscreenRouter.jsx
  54. 36 13
      frontend/src/container/Dashboard.jsx
  55. 11 4
      frontend/src/container/Header.jsx
  56. 33 24
      frontend/src/container/Login.jsx
  57. 1 1
      frontend/src/container/ProgressBar.jsx
  58. 38 39
      frontend/src/container/Sidebar.jsx
  59. 11 7
      frontend/src/container/Tracim.jsx
  60. 30 8
      frontend/src/container/WorkspaceContent.jsx
  61. 6 5
      frontend/src/css/AccountPage.styl
  62. 0 11
      frontend/src/css/AdminWorkspacePage.styl
  63. 22 12
      frontend/src/css/Dashboard.styl
  64. 2 2
      frontend/src/css/Generic.styl
  65. 15 3
      frontend/src/css/Header.styl
  66. 9 13
      frontend/src/css/Login.styl
  67. 0 1
      frontend/src/css/ProgressBar.styl
  68. 94 74
      frontend/src/css/Sidebar.styl
  69. 0 2
      frontend/src/css/index.styl
  70. 6 1
      frontend/src/helper.js
  71. 22 15
      frontend/src/reducer/currentWorkspace.js
  72. 4 1
      frontend/src/reducer/user.js
  73. 30 14
      frontend/src/reducer/workspaceContentList.js
  74. 0 1
      frontend_app_admin_workspace_user/dist/dev
  75. 0 1
      frontend_app_admin_workspace_user/dist/font
  76. 5 5
      frontend_app_admin_workspace_user/dist/index.html
  77. 194 0
      frontend_app_admin_workspace_user/src/component/AdminWorkspace.jsx
  78. 17 17
      frontend_app_admin_workspace_user/src/container/AdminWorkspaceUser.jsx
  79. 20 0
      frontend_app_admin_workspace_user/src/css/index.styl
  80. 0 1
      frontend_app_html-document/dist/dev
  81. 0 1
      frontend_app_html-document/dist/font
  82. 7 7
      frontend_app_html-document/dist/index.html
  83. 50 0
      frontend_app_html-document/src/action.async.js
  84. 28 0
      frontend_app_html-document/src/component/HtmlDocument.jsx
  85. 90 17
      frontend_app_html-document/src/container/HtmlDocument.jsx
  86. 14 3
      frontend_app_html-document/src/css/index.styl
  87. 0 1
      frontend_app_thread/dist/dev
  88. 0 1
      frontend_app_thread/dist/font
  89. 5 5
      frontend_app_thread/dist/index.html
  90. 50 0
      frontend_app_thread/src/action.async.js
  91. 2 2
      frontend_app_thread/src/container/PopupCreateThread.jsx
  92. 101 22
      frontend_app_thread/src/container/Thread.jsx
  93. 0 4
      frontend_app_thread/src/css/index.styl
  94. 0 1
      frontend_app_workspace/dist/dev
  95. 0 1
      frontend_app_workspace/dist/font
  96. 7 7
      frontend_app_workspace/dist/index.html
  97. 0 1
      frontend_lib/dist/dev
  98. 0 1
      frontend_lib/dist/font
  99. 5 5
      frontend_lib/dist/index.html
  100. 0 0
      frontend_lib/package.json

+ 1 - 0
.travis.yml View File

@@ -23,6 +23,7 @@ matrix:
23 23
         - cd backend
24 24
       install:
25 25
         - pip install --upgrade pip setuptools
26
+        - pip install -e .
26 27
         - pip install -e ".[testing]"
27 28
         - pip install pytest-cov
28 29
         - pip install python-coveralls

+ 56 - 7
backend/README.md View File

@@ -77,6 +77,10 @@ Initialize the database using [tracimcli](doc/cli.md) tool
77 77
 
78 78
     tracimcli db init
79 79
 
80
+Stamp current version of database to last (useful for migration):
81
+
82
+    alembic -c development.ini stamp head
83
+
80 84
 create wsgidav configuration file for webdav:
81 85
 
82 86
     cp wsgidav.conf.sample wsgidav.conf
@@ -85,21 +89,32 @@ if not did before, you need to create a color.json file at root of tracim_v2 :
85 89
    
86 90
     cp ../color.json.sample ../color.json
87 91
 
88
-## Run Tracim_backend ##
92
+## Run Tracim_backend With Uwsgi : great for production ##
89 93
 
90
-### With Uwsgi ###
94
+#### Install Uwsgi
91 95
 
92
-Run all services with uwsgi
96
+You can either install uwsgi with pip or with you distrib package manager:
93 97
 
94 98
     # install uwsgi with pip ( unneeded if you already have uwsgi with python3 plugin enabled)
95 99
     sudo pip3 install uwsgi
100
+
101
+or on debian 9 :
102
+
103
+    # install uwsgi on debian 9
104
+    sudo apt install uwsgi uwsgi-plugin-python3
105
+
106
+### All in terminal way ###
107
+
108
+
109
+Run all services with uwsgi
110
+
96 111
     # set tracim_conf_file path
97 112
     export TRACIM_CONF_PATH="$(pwd)/development.ini"
98 113
     export TRACIM_WEBDAV_CONF_PATH="$(pwd)/wsgidav.conf"
99 114
     # pyramid webserver
100
-    uwsgi -d /tmp/tracim_web.log --http-socket :6543 --wsgi-file wsgi/web.py -H env --pidfile /tmp/tracim_web.pid
115
+    uwsgi -d /tmp/tracim_web.log --http-socket :6543 --plugin python3 --wsgi-file wsgi/web.py -H env --pidfile /tmp/tracim_web.pid
101 116
     # webdav wsgidav server
102
-    uwsgi -d /tmp/tracim_webdav.log --http-socket :3030 --wsgi-file wsgi/webdav.py -H env --pidfile /tmp/tracim_webdav.pid
117
+    uwsgi -d /tmp/tracim_webdav.log --http-socket :3030 --plugin python3 --wsgi-file wsgi/webdav.py -H env --pidfile /tmp/tracim_webdav.pid
103 118
 
104 119
 to stop them:
105 120
 
@@ -108,7 +123,37 @@ to stop them:
108 123
     # webdav wsgidav server
109 124
     uwsgi --stop /tmp/tracim_webdav.pid
110 125
 
111
-### With Waitress (legacy way, usefull for debug) ###
126
+## With Uwsgi ini script file ##
127
+
128
+You can also preset uwsgi config for tracim, this way, creating this kind of .ini file:
129
+
130
+    # You need to replace <PATH> with correct absolute path
131
+    [uwsgi]
132
+    plugins = python3
133
+    chdir = <PATH>/tracim_v2/backend/
134
+    module = wsgi.web:application
135
+    home = <PATH>/tracim_v2/backend/env/
136
+    env = TRACIM_CONF_PATH=<PATH>/tracim_v2/backend/development.ini
137
+
138
+and :
139
+
140
+    # You need to replace <PATH> with correct absolute path
141
+    [uwsgi]
142
+    plugins = python3
143
+    chdir = <PATH>/tracim_v2/backend/
144
+    module = wsgi.webdav:application
145
+    home = <PATH>/tracim_v2/backend/env/
146
+    env = TRACIM_CONF_PATH=<PATH>/tracim_v2/backend/development.ini
147
+    env = TRACIM_WEBDAV_CONF_PATH=<PATH>/tracim_v2/backend/wsgidav.conf
148
+
149
+You can then run the process this way :
150
+
151
+    # You need to replace <WSGI_CONF_WEB> with correct path
152
+    uwsgi --ini <WSGI_CONF_WEB>.ini --http-socket :6543
153
+    # You need to replace <WSGI_CONF_WEBDAV> with correct path
154
+    uwsgi --ini <WSGI_CONF_WEBDAV>.ini --http-socket :3030
155
+
156
+### Run Tracim_Backend with Waitress : legacy way, usefull for debug and dev ###
112 157
 
113 158
 run tracim_backend web api:
114 159
 
@@ -163,4 +208,8 @@ For example, with default config:
163 208
 
164 209
 In Tracim, only some user can access to some informations, this is also true in
165 210
 Tracim REST API. you can check the [roles documentation](doc/roles.md) to check
166
-what a specific user can do.
211
+what a specific user can do.
212
+
213
+# Known issues
214
+
215
+see [here](doc/known_issues.md)

+ 23 - 0
backend/doc/known_issues.md View File

@@ -0,0 +1,23 @@
1
+# Known issue with Tracim Backend
2
+
3
+## Uwsgi
4
+
5
+### plaster.exceptions.LoaderNotFound
6
+
7
+If you obtain error with :
8
+
9
+
10
+    plaster.exceptions.LoaderNotFound: Could not find a matching loader for the scheme "file+ini"".
11
+
12
+
13
+you most probably don't set correctly TRACIM_CONF_PATH or TRACIM_WEBDAV_CONF_PATH.
14
+You have to set a correct absolute path.
15
+be careful for uwsgi ini file :
16
+
17
+
18
+    # incorrect
19
+    env = TRACIM_CONF_PATH="/home/me/tracim_v2/backend/development.ini"
20
+
21
+    # correct
22
+    env = TRACIM_CONF_PATH=/home/me/tracim_v2/backend/development.ini
23
+

+ 4 - 0
backend/doc/migration.md View File

@@ -28,6 +28,10 @@ and active the Tracim virtualenv:
28 28
 
29 29
     alembic -c development.ini current
30 30
 
31
+## Set Alembic stamp to last version (first time use) ##
32
+
33
+    alembic -c development.ini stamp head
34
+
31 35
 ### Creating new schema migration ###
32 36
 
33 37
 This creates a new auto-generated python migration file 

+ 4 - 2
backend/doc/roles.md View File

@@ -9,7 +9,7 @@ The other is workspace related and is called "workspace role".
9 9
 
10 10
 |                               | Normal User | Managers    | Admin          |
11 11
 |-------------------------------|-------------|-------------|----------------|
12
-| slug                            | users       | managers    | administrators |
12
+| slug                          | users       | managers    | administrators |
13 13
 |-------------------------------|-------------|-------------|---------|
14 14
 
15 15
 
@@ -20,9 +20,11 @@ The other is workspace related and is called "workspace role".
20 20
 |-------------------------------|-------------|-------------|---------|
21 21
 | create workspace              |  no         | yes         | yes     |
22 22
 | invite user to tracim         |  no         | yes, if manager of a given workspace         | yes     |
23
+| delete workspace              |  no         | yes, if manager of a given workspace         | yes     |
23 24
 |-------------------------------|-------------|-------------|---------|
24 25
 | set user global profile rights|  no         | no          | yes     |
25
-| deactivate user               |  no         | no          | yes     |
26
+| activate/deactivate user      |  no         | no          | yes     |
27
+| delete user/ undelete user    |  no         | no          | yes     |
26 28
 |-------------------------------|-------------|-------------|---------|
27 29
 | access to all user data (/users/{user_id} endpoints) |personal-only|personal-only| yes     |
28 30
 

+ 4 - 0
backend/tracim_backend/__init__.py View File

@@ -1,4 +1,5 @@
1 1
 # -*- coding: utf-8 -*-
2
+
2 3
 try:  # Python 3.5+
3 4
     from http import HTTPStatus
4 5
 except ImportError:
@@ -27,6 +28,7 @@ from tracim_backend.views.core_api.user_controller import UserController
27 28
 from tracim_backend.views.core_api.workspace_controller import WorkspaceController
28 29
 from tracim_backend.views.contents_api.comment_controller import CommentController
29 30
 from tracim_backend.views.contents_api.file_controller import FileController
31
+from tracim_backend.views.contents_api.folder_controller import FolderController
30 32
 from tracim_backend.views.frontend import FrontendController
31 33
 from tracim_backend.views.errors import ErrorSchema
32 34
 from tracim_backend.exceptions import NotAuthenticated
@@ -115,6 +117,7 @@ def web(global_config, **local_settings):
115 117
     html_document_controller = HTMLDocumentController()
116 118
     thread_controller = ThreadController()
117 119
     file_controller = FileController()
120
+    folder_controller = FolderController()
118 121
     configurator.include(session_controller.bind, route_prefix=BASE_API_V2)
119 122
     configurator.include(system_controller.bind, route_prefix=BASE_API_V2)
120 123
     configurator.include(user_controller.bind, route_prefix=BASE_API_V2)
@@ -123,6 +126,7 @@ def web(global_config, **local_settings):
123 126
     configurator.include(html_document_controller.bind, route_prefix=BASE_API_V2)  # nopep8
124 127
     configurator.include(thread_controller.bind, route_prefix=BASE_API_V2)
125 128
     configurator.include(file_controller.bind, route_prefix=BASE_API_V2)
129
+    configurator.include(folder_controller.bind, route_prefix=BASE_API_V2)
126 130
 
127 131
     if app_config.FRONTEND_SERVE:
128 132
         configurator.include('pyramid_mako')

+ 2 - 0
backend/tracim_backend/app_models/applications.py View File

@@ -51,6 +51,7 @@ class Application(object):
51 51
             creation_label: str,
52 52
             available_statuses: typing.List['ContentStatus'],
53 53
             slug_alias: typing.List[str] = None,
54
+            allow_sub_content: bool = False,
54 55
     ):
55 56
         content_type = ContentType(
56 57
             slug=slug,
@@ -60,6 +61,7 @@ class Application(object):
60 61
             creation_label=creation_label,
61 62
             available_statuses=available_statuses,
62 63
             slug_alias=slug_alias,
64
+            allow_sub_content=allow_sub_content,
63 65
         )
64 66
         self.content_types.append(content_type)
65 67
 

+ 31 - 7
backend/tracim_backend/app_models/contents.py View File

@@ -118,6 +118,7 @@ class ContentType(object):
118 118
             creation_label: str,
119 119
             available_statuses: typing.List[ContentStatus],
120 120
             slug_alias: typing.List[str] = None,
121
+            allow_sub_content: bool = False,
121 122
     ):
122 123
         self.slug = slug
123 124
         self.fa_icon = fa_icon
@@ -126,6 +127,7 @@ class ContentType(object):
126 127
         self.creation_label = creation_label
127 128
         self.available_statuses = available_statuses
128 129
         self.slug_alias = slug_alias
130
+        self.allow_sub_content = allow_sub_content
129 131
 
130 132
 
131 133
 thread_type = 'thread'
@@ -188,7 +190,7 @@ class ContentTypeList(object):
188 190
     def _content_types(self):
189 191
         app_api = ApplicationApi(self.app_list)
190 192
         content_types = app_api.get_content_types()
191
-        content_types.extend(self._special_contents_types)
193
+        # content_types.extend(self._special_contents_types)
192 194
         return content_types
193 195
 
194 196
     def get_one_by_slug(self, slug: str) -> ContentType:
@@ -198,21 +200,31 @@ class ContentTypeList(object):
198 200
         """
199 201
         content_types = self._content_types.copy()
200 202
         content_types.extend(self._special_contents_types)
203
+        content_types.append(self.Event)
201 204
         for item in content_types:
202 205
             if item.slug == slug or (item.slug_alias and slug in item.slug_alias):  # nopep8
203 206
                 return item
204 207
         raise ContentTypeNotExist()
205 208
 
206
-    def endpoint_allowed_types_slug(self) -> typing.List[str]:
209
+    def restricted_allowed_types_slug(self) -> typing.List[str]:
207 210
         """
208
-        Return restricted list of content_type:
209
-        dont return special content_type like  comment, don't return
211
+        Return restricted list of content_type: don't return
210 212
         "any" slug, dont return content type slug alias , don't return event.
211 213
         Useful to restrict slug param in schema.
212 214
         """
213 215
         allowed_type_slug = [contents_type.slug for contents_type in self._content_types]  # nopep8
214 216
         return allowed_type_slug
215 217
 
218
+    def endpoint_allowed_types_slug(self) -> typing.List[str]:
219
+        """
220
+        Same as restricted_allowed_types_slug but with special content_type
221
+        included like comments.
222
+        """
223
+        content_types = self._content_types
224
+        content_types.extend(self._special_contents_types)
225
+        allowed_type_slug = [contents_type.slug for contents_type in content_types]  # nopep8
226
+        return allowed_type_slug
227
+
216 228
     def query_allowed_types_slugs(self) -> typing.List[str]:
217 229
         """
218 230
         Return alls allowed types slug : content_type slug + all alias, any
@@ -220,14 +232,26 @@ class ContentTypeList(object):
220 232
         Usefull allowed value to perform query to database.
221 233
         """
222 234
         allowed_types_slug = []
223
-        for content_type in self._content_types:
235
+        content_types = self._content_types
236
+        content_types.extend(self._special_contents_types)
237
+        for content_type in content_types:
224 238
             allowed_types_slug.append(content_type.slug)
225 239
             if content_type.slug_alias:
226 240
                 allowed_types_slug.extend(content_type.slug_alias)
227
-        for content_type in self._special_contents_types:
228
-            allowed_types_slug.append(content_type.slug)
229 241
         allowed_types_slug.extend(self._extra_slugs)
230 242
         return allowed_types_slug
231 243
 
244
+    def default_allowed_content_properties(self, slug) -> dict:
245
+        content_type = self.get_one_by_slug(slug)
246
+        if content_type.allow_sub_content:
247
+            sub_content_allowed = self.endpoint_allowed_types_slug()
248
+        else:
249
+            sub_content_allowed = [self.Comment.slug]
250
+
251
+        properties_dict = {}
252
+        for elem in sub_content_allowed:
253
+            properties_dict[elem] = True
254
+        return properties_dict
255
+
232 256
 
233 257
 CONTENT_TYPES = ContentTypeList(APP_LIST)

+ 5 - 4
backend/tracim_backend/command/user.py View File

@@ -127,7 +127,8 @@ class UserCommand(AppContextCommand):
127 127
                     "You must provide -p/--password parameter"
128 128
                 )
129 129
             password = ''
130
-
130
+        if self._user_api.check_email_already_in_db(login):
131
+            raise UserAlreadyExistError()
131 132
         try:
132 133
             user = self._user_api.create_user(
133 134
                 email=login,
@@ -140,12 +141,12 @@ class UserCommand(AppContextCommand):
140 141
             # daemons = DaemonsManager()
141 142
             # daemons.run('radicale', RadicaleDaemon)
142 143
             self._user_api.execute_created_user_actions(user)
143
-        except IntegrityError:
144
+        except IntegrityError as exception:
144 145
             self._session.rollback()
145
-            raise UserAlreadyExistError()
146
+            raise UserAlreadyExistError() from exception
146 147
         except NotificationNotSend as exception:
147 148
             self._session.rollback()
148
-            raise exception
149
+            raise exception from exception
149 150
 
150 151
         return user
151 152
 

+ 1 - 0
backend/tracim_backend/config.py View File

@@ -551,6 +551,7 @@ class CFG(object):
551 551
             label='Folder',
552 552
             creation_label='Create a folder',
553 553
             available_statuses=CONTENT_STATUS.get_all(),
554
+            allow_sub_content=True,
554 555
         )
555 556
 
556 557
         _file = Application(

+ 8 - 0
backend/tracim_backend/exceptions.py View File

@@ -205,6 +205,10 @@ class PreviewDimNotAllowed(TracimException):
205 205
     pass
206 206
 
207 207
 
208
+class UnallowedSubContent(TracimException):
209
+    pass
210
+
211
+
208 212
 class TooShortAutocompleteString(TracimException):
209 213
     pass
210 214
 
@@ -215,3 +219,7 @@ class PageNotFound(TracimException):
215 219
 
216 220
 class AppDoesNotExist(TracimException):
217 221
     pass
222
+
223
+
224
+class EmailAlreadyExistInDb(TracimException):
225
+    pass

+ 78 - 15
backend/tracim_backend/lib/core/content.py View File

@@ -26,6 +26,8 @@ from sqlalchemy.sql.elements import and_
26 26
 from tracim_backend.lib.utils.utils import cmp_to_key
27 27
 from tracim_backend.lib.core.notifications import NotifierFactory
28 28
 from tracim_backend.exceptions import SameValueError
29
+from tracim_backend.exceptions import UnallowedSubContent
30
+from tracim_backend.exceptions import ContentTypeNotExist
29 31
 from tracim_backend.exceptions import PageOfPreviewNotFound
30 32
 from tracim_backend.exceptions import PreviewDimNotAllowed
31 33
 from tracim_backend.exceptions import RevisionDoesNotMatchThisContent
@@ -408,13 +410,37 @@ class ContentApi(object):
408 410
 
409 411
     def create(self, content_type_slug: str, workspace: Workspace, parent: Content=None, label: str = '', filename: str = '', do_save=False, is_temporary: bool=False, do_notify=True) -> Content:
410 412
         # TODO - G.M - 2018-07-16 - raise Exception instead of assert
411
-        assert content_type_slug in CONTENT_TYPES.query_allowed_types_slugs()
412 413
         assert content_type_slug != CONTENT_TYPES.Any_SLUG
413 414
         assert not (label and filename)
414 415
 
415 416
         if content_type_slug == CONTENT_TYPES.Folder.slug and not label:
416 417
             label = self.generate_folder_label(workspace, parent)
417 418
 
419
+        # TODO BS 2018-08-13: Despite that workspace is required, create_comment
420
+        # can call here with None. Must update create_comment tu require the
421
+        # workspace.
422
+        if not workspace:
423
+            workspace = parent.workspace
424
+
425
+        content_type = CONTENT_TYPES.get_one_by_slug(content_type_slug)
426
+        if parent and parent.properties and 'allowed_content' in parent.properties:
427
+            if content_type.slug not in parent.properties['allowed_content'] or not parent.properties['allowed_content'][content_type.slug]:
428
+                raise UnallowedSubContent(' SubContent of type {subcontent_type}  not allowed in content {content_id}'.format(  # nopep8
429
+                    subcontent_type=content_type.slug,
430
+                    content_id=parent.content_id,
431
+                ))
432
+        if not workspace and parent:
433
+            workspace = parent.workspace
434
+
435
+        if workspace:
436
+            if content_type.slug not in workspace.get_allowed_content_types():
437
+                raise UnallowedSubContent(
438
+                    ' SubContent of type {subcontent_type}  not allowed in workspace {content_id}'.format(  # nopep8
439
+                        subcontent_type=content_type.slug,
440
+                        content_id=workspace.workspace_id,
441
+                    )
442
+                )
443
+
418 444
         content = Content()
419 445
 
420 446
         if filename:
@@ -433,8 +459,9 @@ class ContentApi(object):
433 459
 
434 460
         content.owner = self._user
435 461
         content.parent = parent
462
+
436 463
         content.workspace = workspace
437
-        content.type = content_type_slug
464
+        content.type = content_type.slug
438 465
         content.is_temporary = is_temporary
439 466
         content.revision_type = ActionDescription.CREATION
440 467
 
@@ -450,18 +477,20 @@ class ContentApi(object):
450 477
         return content
451 478
 
452 479
     def create_comment(self, workspace: Workspace=None, parent: Content=None, content:str ='', do_save=False) -> Content:
480
+        # TODO: check parent allowed_type and workspace allowed_ type
453 481
         assert parent and parent.type != CONTENT_TYPES.Folder.slug
454 482
         if not content:
455 483
             raise EmptyCommentContentNotAllowed()
456
-        item = Content()
457
-        item.owner = self._user
458
-        item.parent = parent
459
-        if not workspace:
460
-            workspace = item.parent.workspace
461
-        item.workspace = workspace
462
-        item.type = CONTENT_TYPES.Comment.slug
484
+
485
+        item = self.create(
486
+            content_type_slug=CONTENT_TYPES.Comment.slug,
487
+            workspace=workspace,
488
+            parent=parent,
489
+            do_notify=False,
490
+            do_save=False,
491
+            label='',
492
+        )
463 493
         item.description = content
464
-        item.label = ''
465 494
         item.revision_type = ActionDescription.COMMENT
466 495
 
467 496
         if do_save:
@@ -1001,6 +1030,13 @@ class ContentApi(object):
1001 1030
             else:
1002 1031
                 related_active_content = content
1003 1032
 
1033
+            # INFO - G.M - 2018-08-10 - re-apply general filters here to avoid
1034
+            # issue with comments
1035
+            if not self._show_deleted and related_active_content.is_deleted:
1036
+                continue
1037
+            if not self._show_archived and related_active_content.is_archived:
1038
+                continue
1039
+
1004 1040
             if related_active_content not in active_contents and related_active_content not in too_recent_content:  # nopep8
1005 1041
 
1006 1042
                 if not before_content or before_content_find:
@@ -1076,9 +1112,9 @@ class ContentApi(object):
1076 1112
     #
1077 1113
     #     return result
1078 1114
 
1079
-    def set_allowed_content(self, folder: Content, allowed_content_dict:dict):
1115
+    def _set_allowed_content(self, content: Content, allowed_content_dict: dict) -> None:  # nopep8
1080 1116
         """
1081
-        :param folder: the given folder instance
1117
+        :param content: the given content instance
1082 1118
         :param allowed_content_dict: must be something like this:
1083 1119
             dict(
1084 1120
                 folder = True
@@ -1086,10 +1122,37 @@ class ContentApi(object):
1086 1122
                 file = False,
1087 1123
                 page = True
1088 1124
             )
1089
-        :return:
1125
+        :return: nothing
1126
+        """
1127
+        properties = content.properties.copy()
1128
+        properties['allowed_content'] = allowed_content_dict
1129
+        content.properties = properties
1130
+
1131
+    def set_allowed_content(self, content: Content, allowed_content_type_slug_list: typing.List[str]) -> None:  # nopep8
1132
+        """
1133
+        :param content: the given content instance
1134
+        :param allowed_content_type_slug_list: list of content_type_slug to
1135
+        accept as subcontent.
1136
+        :return: nothing
1137
+        """
1138
+        allowed_content_dict = {}
1139
+        for allowed_content_type_slug in allowed_content_type_slug_list:
1140
+            if allowed_content_type_slug not in CONTENT_TYPES.endpoint_allowed_types_slug():
1141
+                raise ContentTypeNotExist('Content_type {} does not exist'.format(allowed_content_type_slug))  # nopep8
1142
+            allowed_content_dict[allowed_content_type_slug] = True
1143
+
1144
+        self._set_allowed_content(content, allowed_content_dict)
1145
+
1146
+    def restore_content_default_allowed_content(self, content: Content) -> None:
1147
+        """
1148
+        Return to default allowed_content_types
1149
+        :param content: the given content instance
1150
+        :return: nothing
1090 1151
         """
1091
-        properties = dict(allowed_content = allowed_content_dict)
1092
-        folder.properties = properties
1152
+        if content._properties and 'allowed_content' in content._properties:
1153
+            properties = content.properties.copy()
1154
+            del properties['allowed_content']
1155
+            content.properties = properties
1093 1156
 
1094 1157
     def set_status(self, content: Content, new_status: str):
1095 1158
         if new_status in CONTENT_STATUS.get_all_slugs_values():

+ 46 - 9
backend/tracim_backend/lib/core/user.py View File

@@ -13,6 +13,7 @@ from tracim_backend.config import CFG
13 13
 from tracim_backend.models.auth import User
14 14
 from tracim_backend.models.auth import Group
15 15
 from tracim_backend.exceptions import NoUserSetted
16
+from tracim_backend.exceptions import EmailAlreadyExistInDb
16 17
 from tracim_backend.exceptions import TooShortAutocompleteString
17 18
 from tracim_backend.exceptions import PasswordDoNotMatch
18 19
 from tracim_backend.exceptions import EmailValidationFailed
@@ -34,13 +35,18 @@ class UserApi(object):
34 35
             current_user: typing.Optional[User],
35 36
             session: Session,
36 37
             config: CFG,
38
+            show_deleted: bool = False,
37 39
     ) -> None:
38 40
         self._session = session
39 41
         self._user = current_user
40 42
         self._config = config
43
+        self._show_deleted = show_deleted
41 44
 
42 45
     def _base_query(self):
43
-        return self._session.query(User)
46
+        query = self._session.query(User)
47
+        if not self._show_deleted:
48
+            query = query.filter(User.is_deleted == False)
49
+        return query
44 50
 
45 51
     def get_user_with_context(self, user: User) -> UserInContext:
46 52
         """
@@ -267,6 +273,32 @@ class UserApi(object):
267 273
         return user
268 274
 
269 275
     def _check_email(self, email: str) -> bool:
276
+        """
277
+        Check if email is completely ok to be used in user db table
278
+        """
279
+        is_email_correct = self._check_email_correctness(email)
280
+        if not is_email_correct:
281
+            raise EmailValidationFailed(
282
+                'Email given form {} is uncorrect'.format(email))  # nopep8
283
+        email_already_exist_in_db = self.check_email_already_in_db(email)
284
+        if email_already_exist_in_db:
285
+            raise EmailAlreadyExistInDb(
286
+                'Email given {} already exist, please choose something else'.format(email)  # nopep8
287
+            )
288
+        return True
289
+
290
+    def check_email_already_in_db(self, email: str) -> bool:
291
+        """
292
+        Verify if given email does not already exist in db
293
+        """
294
+        return self._session.query(User.email).filter(User.email==email).count() != 0  # nopep8
295
+
296
+    def _check_email_correctness(self, email: str) -> bool:
297
+        """
298
+           Verify if given email is correct:
299
+           - check format
300
+           - futur active check for email ? (dns based ?)
301
+           """
270 302
         # TODO - G.M - 2018-07-05 - find a better way to check email
271 303
         if not email:
272 304
             return False
@@ -288,10 +320,8 @@ class UserApi(object):
288 320
         if name is not None:
289 321
             user.display_name = name
290 322
 
291
-        if email is not None:
292
-            email_exist = self._check_email(email)
293
-            if not email_exist:
294
-                raise EmailValidationFailed('Email given form {} is uncorrect'.format(email))  # nopep8
323
+        if email is not None and email != user.email:
324
+            self._check_email(email)
295 325
             user.email = email
296 326
 
297 327
         if password is not None:
@@ -354,11 +384,8 @@ class UserApi(object):
354 384
             save_now=False
355 385
     ) -> User:
356 386
         """Previous create_user method"""
387
+        self._check_email(email)
357 388
         user = User()
358
-
359
-        email_exist = self._check_email(email)
360
-        if not email_exist:
361
-            raise EmailValidationFailed('Email given form {} is uncorrect'.format(email))  # nopep8
362 389
         user.email = email
363 390
         user.display_name = email.split('@')[0]
364 391
 
@@ -382,6 +409,16 @@ class UserApi(object):
382 409
         if do_save:
383 410
             self.save(user)
384 411
 
412
+    def delete(self, user: User, do_save=False):
413
+        user.is_deleted = True
414
+        if do_save:
415
+            self.save(user)
416
+
417
+    def undelete(self, user: User, do_save=False):
418
+        user.is_deleted = False
419
+        if do_save:
420
+            self.save(user)
421
+
385 422
     def save(self, user: User):
386 423
         self._session.flush()
387 424
 

+ 13 - 12
backend/tracim_backend/lib/core/workspace.py View File

@@ -27,7 +27,8 @@ class WorkspaceApi(object):
27 27
             session: Session,
28 28
             current_user: User,
29 29
             config: CFG,
30
-            force_role: bool=False
30
+            force_role: bool=False,
31
+            show_deleted: bool=False,
31 32
     ):
32 33
         """
33 34
         :param current_user: Current user of context
@@ -37,18 +38,22 @@ class WorkspaceApi(object):
37 38
         self._user = current_user
38 39
         self._config = config
39 40
         self._force_role = force_role
41
+        self.show_deleted = show_deleted
40 42
 
41 43
     def _base_query_without_roles(self):
42
-        return self._session.query(Workspace).filter(Workspace.is_deleted == False)
44
+        query = self._session.query(Workspace)
45
+        if not self.show_deleted:
46
+            query = query.filter(Workspace.is_deleted == False)
47
+        return query
43 48
 
44 49
     def _base_query(self):
45 50
         if not self._force_role and self._user.profile.id>=Group.TIM_ADMIN:
46 51
             return self._base_query_without_roles()
47 52
 
48
-        return self._session.query(Workspace).\
49
-            join(Workspace.roles).\
50
-            filter(UserRoleInWorkspace.user_id == self._user.user_id).\
51
-            filter(Workspace.is_deleted == False)
53
+        query = self._base_query_without_roles()
54
+        query = query.join(Workspace.roles).\
55
+            filter(UserRoleInWorkspace.user_id == self._user.user_id)
56
+        return query
52 57
 
53 58
     def get_workspace_with_context(
54 59
             self,
@@ -207,17 +212,13 @@ class WorkspaceApi(object):
207 212
     def save(self, workspace: Workspace):
208 213
         self._session.flush()
209 214
 
210
-    def delete_one(self, workspace_id, flush=True):
211
-        workspace = self.get_one(workspace_id)
215
+    def delete(self, workspace: Workspace, flush=True):
212 216
         workspace.is_deleted = True
213 217
 
214 218
         if flush:
215 219
             self._session.flush()
216 220
 
217
-    def restore_one(self, workspace_id, flush=True):
218
-        workspace = self._session.query(Workspace)\
219
-            .filter(Workspace.is_deleted==True)\
220
-            .filter(Workspace.workspace_id==workspace_id).one()
221
+    def undelete(self, workspace: Workspace, flush=True):
221 222
         workspace.is_deleted = False
222 223
 
223 224
         if flush:

+ 34 - 0
backend/tracim_backend/lib/utils/authorization.py View File

@@ -89,6 +89,40 @@ def require_profile(group: int) -> typing.Callable:
89 89
     return decorator
90 90
 
91 91
 
92
+def require_profile_or_other_profile_with_workspace_role(
93
+        allow_all_group: int,
94
+        allow_if_role_group: int,
95
+        minimal_required_role: int,
96
+) -> typing.Callable:
97
+    """
98
+    Allow access for allow_all_group profile
99
+    or allow access for allow_if_role_group
100
+    profile if mininal_required_role is correct.
101
+    :param allow_all_group: value from Group Object
102
+    like Group.TIM_USER or Group.TIM_MANAGER
103
+    :param allow_if_role_group: value from Group Object
104
+    like Group.TIM_USER or Group.TIM_MANAGER
105
+    :param minimal_required_role: value from UserInWorkspace Object like
106
+    UserRoleInWorkspace.CONTRIBUTOR or UserRoleInWorkspace.READER
107
+    :return: decorator
108
+    """
109
+    def decorator(func: typing.Callable) -> typing.Callable:
110
+        @functools.wraps(func)
111
+        def wrapper(self, context, request: 'TracimRequest') -> typing.Callable:
112
+            user = request.current_user
113
+            workspace = request.current_workspace
114
+            if user.profile.id >= allow_all_group:
115
+                return func(self, context, request)
116
+            elif user.profile.id >= allow_if_role_group:
117
+                if workspace.get_user_role(user) >= minimal_required_role:
118
+                    return func(self, context, request)
119
+                raise InsufficientUserRoleInWorkspace()
120
+            else:
121
+                raise InsufficientUserProfile()
122
+        return wrapper
123
+    return decorator
124
+
125
+
92 126
 def require_workspace_role(minimal_required_role: int) -> typing.Callable:
93 127
     """
94 128
     Restricts access to endpoint to minimal role or raise an exception.

+ 9 - 3
backend/tracim_backend/lib/utils/request.py View File

@@ -229,6 +229,8 @@ class TracimRequest(Request):
229 229
             api = ContentApi(
230 230
                 current_user=user,
231 231
                 session=request.dbsession,
232
+                show_deleted=True,
233
+                show_archived=True,
232 234
                 config=request.registry.settings['CFG']
233 235
             )
234 236
             comment = api.get_one(
@@ -268,6 +270,8 @@ class TracimRequest(Request):
268 270
                 raise ContentNotFoundInTracimRequest('No content_id property found in request')  # nopep8
269 271
             api = ContentApi(
270 272
                 current_user=user,
273
+                show_deleted=True,
274
+                show_archived=True,
271 275
                 session=request.dbsession,
272 276
                 config=request.registry.settings['CFG']
273 277
             )
@@ -289,7 +293,7 @@ class TracimRequest(Request):
289 293
         :return: user found from header/body
290 294
         """
291 295
         app_config = request.registry.settings['CFG']
292
-        uapi = UserApi(None, session=request.dbsession, config=app_config)
296
+        uapi = UserApi(None, show_deleted=True, session=request.dbsession, config=app_config)
293 297
         login = ''
294 298
         try:
295 299
             login = None
@@ -351,7 +355,8 @@ class TracimRequest(Request):
351 355
             wapi = WorkspaceApi(
352 356
                 current_user=user,
353 357
                 session=request.dbsession,
354
-                config=request.registry.settings['CFG']
358
+                config=request.registry.settings['CFG'],
359
+                show_deleted=True,
355 360
             )
356 361
             workspace = wapi.get_one(workspace_id)
357 362
         except NoResultFound as exc:
@@ -386,7 +391,8 @@ class TracimRequest(Request):
386 391
             wapi = WorkspaceApi(
387 392
                 current_user=user,
388 393
                 session=request.dbsession,
389
-                config=request.registry.settings['CFG']
394
+                config=request.registry.settings['CFG'],
395
+                show_deleted=True,
390 396
             )
391 397
             workspace = wapi.get_one(workspace_id)
392 398
         except JSONDecodeError as exc:

+ 0 - 8
backend/tracim_backend/lib/webdav/resources.py View File

@@ -295,14 +295,6 @@ class WorkspaceResource(DAVCollection):
295 295
             parent=self.content
296 296
         )
297 297
 
298
-        subcontent = dict(
299
-            folder=True,
300
-            thread=True,
301
-            file=True,
302
-            page=True
303
-        )
304
-
305
-        self.content_api.set_allowed_content(folder, subcontent)
306 298
         self.content_api.save(folder)
307 299
 
308 300
         transaction.commit()

+ 26 - 0
backend/tracim_backend/migration/versions/78b52ca39419_add_is_deleted_to_user.py View File

@@ -0,0 +1,26 @@
1
+"""add_is_deleted_to_user
2
+
3
+Revision ID: 78b52ca39419
4
+Revises: ad79f58ec2bf
5
+Create Date: 2018-08-09 15:50:49.656925
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = '78b52ca39419'
11
+down_revision = 'ad79f58ec2bf'
12
+
13
+from alembic import op
14
+import sqlalchemy as sa
15
+
16
+
17
+def upgrade():
18
+    # ### commands auto generated by Alembic - please adjust! ###
19
+    op.add_column('users', sa.Column('is_deleted', sa.Boolean(), server_default=sa.sql.expression.literal(False), nullable=False))
20
+    # ### end Alembic commands ###
21
+
22
+
23
+def downgrade():
24
+    # ### commands auto generated by Alembic - please adjust! ###
25
+    op.drop_column('users', 'is_deleted')
26
+    # ### end Alembic commands ###

+ 2 - 0
backend/tracim_backend/models/auth.py View File

@@ -15,6 +15,7 @@ from datetime import datetime
15 15
 from hashlib import sha256
16 16
 from typing import TYPE_CHECKING
17 17
 
18
+import sqlalchemy
18 19
 from sqlalchemy import Column
19 20
 from sqlalchemy import ForeignKey
20 21
 from sqlalchemy import Sequence
@@ -135,6 +136,7 @@ class User(DeclarativeBase):
135 136
     _password = Column('password', Unicode(128))
136 137
     created = Column(DateTime, default=datetime.utcnow)
137 138
     is_active = Column(Boolean, default=True, nullable=False)
139
+    is_deleted = Column(Boolean, default=False, nullable=False, server_default=sqlalchemy.sql.expression.literal(False))
138 140
     imported_from = Column(Unicode(32), nullable=True)
139 141
     timezone = Column(Unicode(255), nullable=False, server_default='')
140 142
     # TODO - G.M - 04-04-2018 - [auth] Check if this is already needed

+ 50 - 20
backend/tracim_backend/models/context_models.py View File

@@ -10,10 +10,12 @@ from tracim_backend.config import PreviewDim
10 10
 from tracim_backend.extensions import APP_LIST
11 11
 from tracim_backend.lib.core.application import ApplicationApi
12 12
 from tracim_backend.lib.utils.utils import get_root_frontend_url
13
+from tracim_backend.lib.utils.utils import password_generator
13 14
 from tracim_backend.lib.utils.utils import CONTENT_FRONTEND_URL_SCHEMA
14 15
 from tracim_backend.lib.utils.utils import WORKSPACE_FRONTEND_URL_SCHEMA
15 16
 from tracim_backend.models import User
16 17
 from tracim_backend.models.auth import Profile
18
+from tracim_backend.models.auth import Group
17 19
 from tracim_backend.models.data import Content
18 20
 from tracim_backend.models.data import ContentRevisionRO
19 21
 from tracim_backend.models.data import Workspace
@@ -100,17 +102,19 @@ class UserCreation(object):
100 102
     def __init__(
101 103
             self,
102 104
             email: str,
103
-            password: str,
104
-            public_name: str,
105
-            timezone: str,
106
-            profile: str,
107
-            email_notification: str,
105
+            password: str = None,
106
+            public_name: str = None,
107
+            timezone: str = None,
108
+            profile: str = None,
109
+            email_notification: bool = True,
108 110
     ) -> None:
109 111
         self.email = email
110
-        self.password = password
111
-        self.public_name = public_name
112
-        self.timezone = timezone
113
-        self.profile = profile
112
+        # INFO - G.M - 2018-08-16 - cleartext password, default value
113
+        # is auto-generated.
114
+        self.password = password or password_generator()
115
+        self.public_name = public_name or None
116
+        self.timezone = timezone or ''
117
+        self.profile = profile or Group.TIM_USER_GROUPNAME
114 118
         self.email_notification = email_notification
115 119
 
116 120
 
@@ -296,10 +300,10 @@ class ContentCreation(object):
296 300
     Content creation model
297 301
     """
298 302
     def __init__(
299
-            self,
300
-            label: str,
301
-            content_type: str,
302
-            parent_id: typing.Optional[int] = None,
303
+        self,
304
+        label: str,
305
+        content_type: str,
306
+        parent_id: typing.Optional[int] = None,
303 307
     ) -> None:
304 308
         self.label = label
305 309
         self.content_type = content_type
@@ -311,8 +315,8 @@ class CommentCreation(object):
311 315
     Comment creation model
312 316
     """
313 317
     def __init__(
314
-            self,
315
-            raw_content: str,
318
+        self,
319
+        raw_content: str,
316 320
     ) -> None:
317 321
         self.raw_content = raw_content
318 322
 
@@ -322,8 +326,8 @@ class SetContentStatus(object):
322 326
     Set content status
323 327
     """
324 328
     def __init__(
325
-            self,
326
-            status: str,
329
+        self,
330
+        status: str,
327 331
     ) -> None:
328 332
         self.status = status
329 333
 
@@ -333,12 +337,27 @@ class TextBasedContentUpdate(object):
333 337
     TextBasedContent update model
334 338
     """
335 339
     def __init__(
336
-            self,
337
-            label: str,
338
-            raw_content: str,
340
+        self,
341
+        label: str,
342
+        raw_content: str,
343
+    ) -> None:
344
+        self.label = label
345
+        self.raw_content = raw_content
346
+
347
+
348
+class FolderContentUpdate(object):
349
+    """
350
+    Folder Content update model
351
+    """
352
+    def __init__(
353
+        self,
354
+        label: str,
355
+        raw_content: str,
356
+        sub_content_types: typing.List[str],
339 357
     ) -> None:
340 358
         self.label = label
341 359
         self.raw_content = raw_content
360
+        self.sub_content_types = sub_content_types
342 361
 
343 362
 
344 363
 class TypeUser(Enum):
@@ -392,6 +411,10 @@ class UserInContext(object):
392 411
     def profile(self) -> Profile:
393 412
         return self.user.profile.name
394 413
 
414
+    @property
415
+    def is_deleted(self) -> bool:
416
+        return self.user.is_deleted
417
+
395 418
     # Context related
396 419
 
397 420
     @property
@@ -456,6 +479,13 @@ class WorkspaceInContext(object):
456 479
         return slugify(self.workspace.label)
457 480
 
458 481
     @property
482
+    def is_deleted(self) -> bool:
483
+        """
484
+        Is the workspace deleted ?
485
+        """
486
+        return self.workspace.is_deleted
487
+
488
+    @property
459 489
     def sidebar_entries(self) -> typing.List[WorkspaceMenuEntry]:
460 490
         """
461 491
         get sidebar entries, those depends on activated apps.

+ 23 - 42
backend/tracim_backend/models/data.py View File

@@ -32,15 +32,6 @@ from tracim_backend.models.meta import DeclarativeBase
32 32
 from tracim_backend.models.auth import User
33 33
 from tracim_backend.models.roles import WorkspaceRoles
34 34
 
35
-DEFAULT_PROPERTIES = dict(
36
-    allowed_content=dict(
37
-        folder=True,
38
-        file=True,
39
-        page=True,
40
-        thread=True,
41
-    ),
42
-)
43
-
44 35
 
45 36
 class Workspace(DeclarativeBase):
46 37
 
@@ -545,22 +536,8 @@ class ContentChecker(object):
545 536
 
546 537
     @classmethod
547 538
     def check_properties(cls, item):
548
-        if item.type == CONTENT_TYPES.Folder.slug:
549
-            properties = item.properties
550
-            if 'allowed_content' not in properties.keys():
551
-                return False
552
-            if 'folders' not in properties['allowed_content']:
553
-                return False
554
-            if 'files' not in properties['allowed_content']:
555
-                return False
556
-            if 'pages' not in properties['allowed_content']:
557
-                return False
558
-            if 'threads' not in properties['allowed_content']:
559
-                return False
560
-            return True
561
-
539
+        properties = item.properties
562 540
         if item.type == CONTENT_TYPES.Event.slug:
563
-            properties = item.properties
564 541
             if 'name' not in properties.keys():
565 542
                 return False
566 543
             if 'raw' not in properties.keys():
@@ -570,22 +547,16 @@ class ContentChecker(object):
570 547
             if 'end' not in properties.keys():
571 548
                 return False
572 549
             return True
573
-
574
-        # TODO - G.M - 15-03-2018 - Choose only correct Content-type for origin
575
-        # Only content who can be copied need this
576
-        if item.type == CONTENT_TYPES.Any_SLUG:
577
-            properties = item.properties
550
+        else:
551
+            if 'allowed_content' in properties.keys():
552
+                for content_slug, value in properties['allowed_content'].items():  # nopep8
553
+                    if not isinstance(value, bool):
554
+                        return False
555
+                    if not content_slug in CONTENT_TYPES.endpoint_allowed_types_slug():  # nopep8
556
+                        return False
578 557
             if 'origin' in properties.keys():
579
-                return True
580
-        raise NotImplementedError
581
-
582
-    @classmethod
583
-    def reset_properties(cls, item):
584
-        if item.type == CONTENT_TYPES.Folder.slug:
585
-            item.properties = DEFAULT_PROPERTIES
586
-            return
587
-
588
-        raise NotImplementedError
558
+                pass
559
+            return True
589 560
 
590 561
 
591 562
 class ContentRevisionRO(DeclarativeBase):
@@ -1204,9 +1175,15 @@ class Content(DeclarativeBase):
1204 1175
     @hybrid_property
1205 1176
     def properties(self) -> dict:
1206 1177
         """ return a structure decoded from json content of _properties """
1178
+
1207 1179
         if not self._properties:
1208
-            return DEFAULT_PROPERTIES
1209
-        return json.loads(self._properties)
1180
+            properties = {}
1181
+        else:
1182
+            properties = json.loads(self._properties)
1183
+        if CONTENT_TYPES.get_one_by_slug(self.type) != CONTENT_TYPES.Event:
1184
+            if not 'allowed_content' in properties:
1185
+                properties['allowed_content'] = CONTENT_TYPES.default_allowed_content_properties(self.type)  # nopep8
1186
+        return properties
1210 1187
 
1211 1188
     @properties.setter
1212 1189
     def properties(self, properties_struct: dict) -> None:
@@ -1347,7 +1324,11 @@ class Content(DeclarativeBase):
1347 1324
             allowed_types = self.properties['allowed_content']
1348 1325
             for type_label, is_allowed in allowed_types.items():
1349 1326
                 if is_allowed:
1350
-                    types.append(CONTENT_TYPES.get_one_by_slug(type_label))
1327
+                   types.append(
1328
+                        CONTENT_TYPES.get_one_by_slug(type_label)
1329
+                   )
1330
+        # TODO BS 2018-08-13: This try/except is not correct: except exception
1331
+        # if we know what to except.
1351 1332
         except Exception as e:
1352 1333
             print(e.__str__())
1353 1334
             print('----- /*\ *****')

+ 809 - 0
backend/tracim_backend/tests/functional/test_contents.py View File

@@ -24,6 +24,767 @@ from tracim_backend.tests import set_html_document_slug_to_legacy
24 24
 from tracim_backend.fixtures.content import Content as ContentFixtures
25 25
 from tracim_backend.fixtures.users_and_groups import Base as BaseFixture
26 26
 
27
+class TestFolder(FunctionalTest):
28
+    """
29
+    Tests for /api/v2/workspaces/{workspace_id}/folders/{content_id}
30
+    endpoint
31
+    """
32
+
33
+    fixtures = [BaseFixture]
34
+
35
+    def test_api__get_folder__ok_200__nominal_case(self) -> None:
36
+        """
37
+        Get one folder content
38
+        """
39
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
40
+        admin = dbsession.query(models.User) \
41
+            .filter(models.User.email == 'admin@admin.admin') \
42
+            .one()
43
+        workspace_api = WorkspaceApi(
44
+            current_user=admin,
45
+            session=dbsession,
46
+            config=self.app_config
47
+        )
48
+        content_api = ContentApi(
49
+            current_user=admin,
50
+            session=dbsession,
51
+            config=self.app_config
52
+        )
53
+        test_workspace = workspace_api.create_workspace(
54
+            label='test',
55
+            save_now=True,
56
+        )
57
+        folder = content_api.create(
58
+            label='test-folder',
59
+            content_type_slug=CONTENT_TYPES.Folder.slug,
60
+            workspace=test_workspace,
61
+            do_save=True,
62
+            do_notify=False
63
+        )
64
+        transaction.commit()
65
+
66
+        self.testapp.authorization = (
67
+            'Basic',
68
+            (
69
+                'admin@admin.admin',
70
+                'admin@admin.admin'
71
+            )
72
+        )
73
+        res = self.testapp.get(
74
+            '/api/v2/workspaces/{workspace_id}/folders/{content_id}'.format(
75
+                workspace_id=test_workspace.workspace_id,
76
+                content_id=folder.content_id,
77
+            ),
78
+            status=200
79
+        )
80
+        content = res.json_body
81
+        assert content['content_type'] == 'folder'
82
+        assert content['content_id'] == folder.content_id
83
+        assert content['is_archived'] is False
84
+        assert content['is_deleted'] is False
85
+        assert content['label'] == 'test-folder'
86
+        assert content['parent_id'] is None
87
+        assert content['show_in_ui'] is True
88
+        assert content['slug'] == 'test-folder'
89
+        assert content['status'] == 'open'
90
+        assert content['workspace_id'] == test_workspace.workspace_id
91
+        assert content['current_revision_id'] == folder.revision_id
92
+        # TODO - G.M - 2018-06-173 - check date format
93
+        assert content['created']
94
+        assert content['author']
95
+        assert content['author']['user_id'] == 1
96
+        assert content['author']['avatar_url'] is None
97
+        assert content['author']['public_name'] == 'Global manager'
98
+        # TODO - G.M - 2018-06-173 - check date format
99
+        assert content['modified']
100
+        assert content['last_modifier']['user_id'] == 1
101
+        assert content['last_modifier']['public_name'] == 'Global manager'
102
+        assert content['last_modifier']['avatar_url'] is None
103
+        assert content['raw_content'] == ''
104
+
105
+    def test_api__get_folder__err_400__wrong_content_type(self) -> None:
106
+        """
107
+        Get one folder of a content content 7 is not folder
108
+        """
109
+        self.testapp.authorization = (
110
+            'Basic',
111
+            (
112
+                'admin@admin.admin',
113
+                'admin@admin.admin'
114
+            )
115
+        )
116
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
117
+        admin = dbsession.query(models.User) \
118
+            .filter(models.User.email == 'admin@admin.admin') \
119
+            .one()
120
+        workspace_api = WorkspaceApi(
121
+            current_user=admin,
122
+            session=dbsession,
123
+            config=self.app_config
124
+        )
125
+        content_api = ContentApi(
126
+            current_user=admin,
127
+            session=dbsession,
128
+            config=self.app_config
129
+        )
130
+        test_workspace = workspace_api.create_workspace(
131
+            label='test',
132
+            save_now=True,
133
+        )
134
+        thread = content_api.create(
135
+            label='thread',
136
+            content_type_slug=CONTENT_TYPES.Thread.slug,
137
+            workspace=test_workspace,
138
+            do_save=True,
139
+            do_notify=False
140
+        )
141
+        transaction.commit()
142
+        self.testapp.get(
143
+            '/api/v2/workspaces/2/folders/7',
144
+            status=400
145
+        )
146
+
147
+    def test_api__get_folder__err_400__content_does_not_exist(self) -> None:  # nopep8
148
+        """
149
+        Get one folder content (content 170 does not exist in db)
150
+        """
151
+        self.testapp.authorization = (
152
+            'Basic',
153
+            (
154
+                'admin@admin.admin',
155
+                'admin@admin.admin'
156
+            )
157
+        )
158
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
159
+        admin = dbsession.query(models.User) \
160
+            .filter(models.User.email == 'admin@admin.admin') \
161
+            .one()
162
+        workspace_api = WorkspaceApi(
163
+            current_user=admin,
164
+            session=dbsession,
165
+            config=self.app_config
166
+        )
167
+        content_api = ContentApi(
168
+            current_user=admin,
169
+            session=dbsession,
170
+            config=self.app_config
171
+        )
172
+        test_workspace = workspace_api.create_workspace(
173
+            label='test',
174
+            save_now=True,
175
+        )
176
+        transaction.commit()
177
+        self.testapp.get(
178
+            '/api/v2/workspaces/{workspace_id}/folders/170'.format(workspace_id=test_workspace.workspace_id),  # nopep8
179
+            status=400
180
+        )
181
+
182
+    def test_api__get_folder__err_400__content_not_in_workspace(self) -> None:  # nopep8
183
+        """
184
+        Get one folders of a content (content is in another workspace)
185
+        """
186
+        self.testapp.authorization = (
187
+            'Basic',
188
+            (
189
+                'admin@admin.admin',
190
+                'admin@admin.admin'
191
+            )
192
+        )
193
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
194
+        admin = dbsession.query(models.User) \
195
+            .filter(models.User.email == 'admin@admin.admin') \
196
+            .one()
197
+        workspace_api = WorkspaceApi(
198
+            current_user=admin,
199
+            session=dbsession,
200
+            config=self.app_config
201
+        )
202
+        content_api = ContentApi(
203
+            current_user=admin,
204
+            session=dbsession,
205
+            config=self.app_config
206
+        )
207
+        test_workspace = workspace_api.create_workspace(
208
+            label='test',
209
+            save_now=True,
210
+        )
211
+        folder = content_api.create(
212
+            label='test_folder',
213
+            content_type_slug=CONTENT_TYPES.Folder.slug,
214
+            workspace=test_workspace,
215
+            do_save=True,
216
+            do_notify=False
217
+        )
218
+        test_workspace2 = workspace_api.create_workspace(
219
+            label='test2',
220
+            save_now=True,
221
+        )
222
+        transaction.commit()
223
+        self.testapp.authorization = (
224
+            'Basic',
225
+            (
226
+                'admin@admin.admin',
227
+                'admin@admin.admin'
228
+            )
229
+        )
230
+        self.testapp.get(
231
+            '/api/v2/workspaces/{workspace_id}/folders/{content_id}'.format(
232
+                workspace_id=test_workspace2.workspace_id,
233
+                content_id=folder.content_id,
234
+            ),
235
+            status=400
236
+        )
237
+
238
+    def test_api__get_folder__err_400__workspace_does_not_exist(self) -> None:  # nopep8
239
+        """
240
+        Get one folder content (Workspace 40 does not exist)
241
+        """
242
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
243
+        admin = dbsession.query(models.User) \
244
+            .filter(models.User.email == 'admin@admin.admin') \
245
+            .one()
246
+        workspace_api = WorkspaceApi(
247
+            current_user=admin,
248
+            session=dbsession,
249
+            config=self.app_config
250
+        )
251
+        content_api = ContentApi(
252
+            current_user=admin,
253
+            session=dbsession,
254
+            config=self.app_config
255
+        )
256
+        test_workspace = workspace_api.create_workspace(
257
+            label='test',
258
+            save_now=True,
259
+        )
260
+        folder = content_api.create(
261
+            label='test_folder',
262
+            content_type_slug=CONTENT_TYPES.Folder.slug,
263
+            workspace=test_workspace,
264
+            do_save=True,
265
+            do_notify=False
266
+        )
267
+        transaction.commit()
268
+        self.testapp.authorization = (
269
+            'Basic',
270
+            (
271
+                'admin@admin.admin',
272
+                'admin@admin.admin'
273
+            )
274
+        )
275
+        self.testapp.get(
276
+            '/api/v2/workspaces/40/folders/{content_id}'.format(content_id=folder.content_id),  # nopep8
277
+            status=400
278
+        )
279
+
280
+    def test_api__get_folder__err_400__workspace_id_is_not_int(self) -> None:  # nopep8
281
+        """
282
+        Get one folder content, workspace id is not int
283
+        """
284
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
285
+        admin = dbsession.query(models.User) \
286
+            .filter(models.User.email == 'admin@admin.admin') \
287
+            .one()
288
+        workspace_api = WorkspaceApi(
289
+            current_user=admin,
290
+            session=dbsession,
291
+            config=self.app_config
292
+        )
293
+        content_api = ContentApi(
294
+            current_user=admin,
295
+            session=dbsession,
296
+            config=self.app_config
297
+        )
298
+        test_workspace = workspace_api.create_workspace(
299
+            label='test',
300
+            save_now=True,
301
+        )
302
+        folder = content_api.create(
303
+            label='test_folder',
304
+            content_type_slug=CONTENT_TYPES.Folder.slug,
305
+            workspace=test_workspace,
306
+            do_save=True,
307
+            do_notify=False
308
+        )
309
+        transaction.commit()
310
+        self.testapp.authorization = (
311
+            'Basic',
312
+            (
313
+                'admin@admin.admin',
314
+                'admin@admin.admin'
315
+            )
316
+        )
317
+        self.testapp.get(
318
+            '/api/v2/workspaces/coucou/folders/{content_id}'.format(content_id=folder.content_id),  # nopep8
319
+            status=400
320
+        )
321
+
322
+    def test_api__get_folder__err_400__content_id_is_not_int(self) -> None:  # nopep8
323
+        """
324
+        Get one folder content, content_id is not int
325
+        """
326
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
327
+        admin = dbsession.query(models.User) \
328
+            .filter(models.User.email == 'admin@admin.admin') \
329
+            .one()
330
+        workspace_api = WorkspaceApi(
331
+            current_user=admin,
332
+            session=dbsession,
333
+            config=self.app_config
334
+        )
335
+        content_api = ContentApi(
336
+            current_user=admin,
337
+            session=dbsession,
338
+            config=self.app_config
339
+        )
340
+        test_workspace = workspace_api.create_workspace(
341
+            label='test',
342
+            save_now=True,
343
+        )
344
+        folder = content_api.create(
345
+            label='test_folder',
346
+            content_type_slug=CONTENT_TYPES.Folder.slug,
347
+            workspace=test_workspace,
348
+            do_save=True,
349
+            do_notify=False
350
+        )
351
+        transaction.commit()
352
+
353
+        self.testapp.authorization = (
354
+            'Basic',
355
+            (
356
+                'admin@admin.admin',
357
+                'admin@admin.admin'
358
+            )
359
+        )
360
+        self.testapp.get(
361
+            '/api/v2/workspaces/{workspace_id}/folders/coucou'.format(workspace_id=test_workspace.workspace_id),  # nopep8
362
+            status=400
363
+        )
364
+
365
+    def test_api__update_folder__err_400__empty_label(self) -> None:  # nopep8
366
+        """
367
+        Update(put) one folder content
368
+        """
369
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
370
+        admin = dbsession.query(models.User) \
371
+            .filter(models.User.email == 'admin@admin.admin') \
372
+            .one()
373
+        workspace_api = WorkspaceApi(
374
+            current_user=admin,
375
+            session=dbsession,
376
+            config=self.app_config
377
+        )
378
+        content_api = ContentApi(
379
+            current_user=admin,
380
+            session=dbsession,
381
+            config=self.app_config
382
+        )
383
+        test_workspace = workspace_api.create_workspace(
384
+            label='test',
385
+            save_now=True,
386
+        )
387
+        folder = content_api.create(
388
+            label='test_folder',
389
+            content_type_slug=CONTENT_TYPES.Folder.slug,
390
+            workspace=test_workspace,
391
+            do_save=True,
392
+            do_notify=False
393
+        )
394
+        transaction.commit()
395
+        self.testapp.authorization = (
396
+            'Basic',
397
+            (
398
+                'admin@admin.admin',
399
+                'admin@admin.admin'
400
+            )
401
+        )
402
+        params = {
403
+            'label': '',
404
+            'raw_content': '<p> Le nouveau contenu </p>',
405
+            'sub_content_types': [CONTENT_TYPES.Folder.slug]
406
+        }
407
+        self.testapp.put_json(
408
+            '/api/v2/workspaces/{workspace_id}/folders/{content_id}'.format(
409
+                workspace_id=test_workspace.workspace_id,
410
+                content_id=folder.content_id,
411
+            ),
412
+            params=params,
413
+            status=400
414
+        )
415
+
416
+    def test_api__update_folder__ok_200__nominal_case(self) -> None:
417
+        """
418
+        Update(put) one html document of a content
419
+        """
420
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
421
+        admin = dbsession.query(models.User) \
422
+            .filter(models.User.email == 'admin@admin.admin') \
423
+            .one()
424
+        workspace_api = WorkspaceApi(
425
+            current_user=admin,
426
+            session=dbsession,
427
+            config=self.app_config
428
+        )
429
+        content_api = ContentApi(
430
+            current_user=admin,
431
+            session=dbsession,
432
+            config=self.app_config
433
+        )
434
+        test_workspace = workspace_api.create_workspace(
435
+            label='test',
436
+            save_now=True,
437
+        )
438
+        folder = content_api.create(
439
+            label='test_folder',
440
+            content_type_slug=CONTENT_TYPES.Folder.slug,
441
+            workspace=test_workspace,
442
+            do_save=True,
443
+            do_notify=False
444
+        )
445
+        transaction.commit()
446
+        self.testapp.authorization = (
447
+            'Basic',
448
+            (
449
+                'admin@admin.admin',
450
+                'admin@admin.admin'
451
+            )
452
+        )
453
+        params = {
454
+            'label': 'My New label',
455
+            'raw_content': '<p> Le nouveau contenu </p>',
456
+            'sub_content_types': [CONTENT_TYPES.Folder.slug]
457
+        }
458
+        res = self.testapp.put_json(
459
+            '/api/v2/workspaces/{workspace_id}/folders/{content_id}'.format(
460
+                workspace_id=test_workspace.workspace_id,
461
+                content_id=folder.content_id,
462
+            ),
463
+            params=params,
464
+            status=200
465
+        )
466
+        content = res.json_body
467
+        assert content['content_type'] == 'folder'
468
+        assert content['content_id'] == folder.content_id
469
+        assert content['is_archived'] is False
470
+        assert content['is_deleted'] is False
471
+        assert content['label'] == 'My New label'
472
+        assert content['parent_id'] is None
473
+        assert content['show_in_ui'] is True
474
+        assert content['slug'] == 'my-new-label'
475
+        assert content['status'] == 'open'
476
+        assert content['workspace_id'] == test_workspace.workspace_id
477
+        assert content['current_revision_id']
478
+        # TODO - G.M - 2018-06-173 - check date format
479
+        assert content['created']
480
+        assert content['author']
481
+        assert content['author']['user_id'] == 1
482
+        assert content['author']['avatar_url'] is None
483
+        assert content['author']['public_name'] == 'Global manager'
484
+        # TODO - G.M - 2018-06-173 - check date format
485
+        assert content['modified']
486
+        assert content['last_modifier'] == content['author']
487
+        assert content['raw_content'] == '<p> Le nouveau contenu </p>'
488
+        assert content['sub_content_types'] == [CONTENT_TYPES.Folder.slug]
489
+
490
+    def test_api__get_folder_revisions__ok_200__nominal_case(
491
+            self
492
+    ) -> None:
493
+        """
494
+        Get one html document of a content
495
+        """
496
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
497
+        admin = dbsession.query(models.User) \
498
+            .filter(models.User.email == 'admin@admin.admin') \
499
+            .one()
500
+        workspace_api = WorkspaceApi(
501
+            current_user=admin,
502
+            session=dbsession,
503
+            config=self.app_config
504
+        )
505
+        content_api = ContentApi(
506
+            current_user=admin,
507
+            session=dbsession,
508
+            config=self.app_config
509
+        )
510
+        test_workspace = workspace_api.create_workspace(
511
+            label='test',
512
+            save_now=True,
513
+        )
514
+        folder = content_api.create(
515
+            label='test-folder',
516
+            content_type_slug=CONTENT_TYPES.Folder.slug,
517
+            workspace=test_workspace,
518
+            do_save=True,
519
+            do_notify=False
520
+        )
521
+        with new_revision(
522
+           session=dbsession,
523
+           tm=transaction.manager,
524
+           content=folder,
525
+        ):
526
+            content_api.update_content(
527
+                folder,
528
+                new_label='test-folder-updated',
529
+                new_content='Just a test'
530
+            )
531
+        content_api.save(folder)
532
+        with new_revision(
533
+           session=dbsession,
534
+           tm=transaction.manager,
535
+           content=folder,
536
+        ):
537
+            content_api.archive(
538
+                folder,
539
+            )
540
+        content_api.save(folder)
541
+        with new_revision(
542
+           session=dbsession,
543
+           tm=transaction.manager,
544
+           content=folder,
545
+        ):
546
+            content_api.unarchive(
547
+                folder,
548
+            )
549
+        content_api.save(folder)
550
+        transaction.commit()
551
+        self.testapp.authorization = (
552
+            'Basic',
553
+            (
554
+                'admin@admin.admin',
555
+                'admin@admin.admin'
556
+            )
557
+        )
558
+        res = self.testapp.get(
559
+            '/api/v2/workspaces/{workspace_id}/folders/{content_id}/revisions'.format(  # nopep8
560
+                workspace_id=test_workspace.workspace_id,
561
+                content_id=folder.content_id,
562
+            ),
563
+            status=200
564
+        )
565
+        revisions = res.json_body
566
+        assert len(revisions) == 4
567
+        revision = revisions[0]
568
+        assert revision['content_type'] == 'folder'
569
+        assert revision['content_id'] == folder.content_id
570
+        assert revision['is_archived'] is False
571
+        assert revision['is_deleted'] is False
572
+        assert revision['label'] == 'test-folder'
573
+        assert revision['parent_id'] is None
574
+        assert revision['show_in_ui'] is True
575
+        assert revision['slug'] == 'test-folder'
576
+        assert revision['status'] == 'open'
577
+        assert revision['workspace_id'] == test_workspace.workspace_id
578
+        assert revision['revision_id']
579
+        assert revision['revision_type'] == 'creation'
580
+        assert revision['sub_content_types']
581
+        # TODO - G.M - 2018-06-173 - Test with real comments
582
+        assert revision['comment_ids'] == []
583
+        # TODO - G.M - 2018-06-173 - check date format
584
+        assert revision['created']
585
+        assert revision['author']
586
+        assert revision['author']['user_id'] == 1
587
+        assert revision['author']['avatar_url'] is None
588
+        assert revision['author']['public_name'] == 'Global manager'
589
+
590
+        revision = revisions[1]
591
+        assert revision['content_type'] == 'folder'
592
+        assert revision['content_id'] == folder.content_id
593
+        assert revision['is_archived'] is False
594
+        assert revision['is_deleted'] is False
595
+        assert revision['label'] == 'test-folder-updated'
596
+        assert revision['parent_id'] is None
597
+        assert revision['show_in_ui'] is True
598
+        assert revision['slug'] == 'test-folder-updated'
599
+        assert revision['status'] == 'open'
600
+        assert revision['workspace_id'] == test_workspace.workspace_id
601
+        assert revision['revision_id']
602
+        assert revision['revision_type'] == 'edition'
603
+        assert revision['sub_content_types']
604
+        # TODO - G.M - 2018-06-173 - Test with real comments
605
+        assert revision['comment_ids'] == []
606
+        # TODO - G.M - 2018-06-173 - check date format
607
+        assert revision['created']
608
+        assert revision['author']
609
+        assert revision['author']['user_id'] == 1
610
+        assert revision['author']['avatar_url'] is None
611
+        assert revision['author']['public_name'] == 'Global manager'
612
+
613
+        revision = revisions[2]
614
+        assert revision['content_type'] == 'folder'
615
+        assert revision['content_id'] == folder.content_id
616
+        assert revision['is_archived'] is True
617
+        assert revision['is_deleted'] is False
618
+        assert revision['label'] != 'test-folder-updated'
619
+        assert revision['label'].startswith('test-folder-updated')
620
+        assert revision['parent_id'] is None
621
+        assert revision['show_in_ui'] is True
622
+        assert revision['slug'] != 'test-folder-updated'
623
+        assert revision['slug'].startswith('test-folder-updated')
624
+        assert revision['status'] == 'open'
625
+        assert revision['workspace_id'] == test_workspace.workspace_id
626
+        assert revision['revision_id']
627
+        assert revision['revision_type'] == 'archiving'
628
+        assert revision['sub_content_types']
629
+        # TODO - G.M - 2018-06-173 - Test with real comments
630
+        assert revision['comment_ids'] == []
631
+        # TODO - G.M - 2018-06-173 - check date format
632
+        assert revision['created']
633
+        assert revision['author']
634
+        assert revision['author']['user_id'] == 1
635
+        assert revision['author']['avatar_url'] is None
636
+        assert revision['author']['public_name'] == 'Global manager'
637
+
638
+        revision = revisions[3]
639
+        assert revision['content_type'] == 'folder'
640
+        assert revision['content_id'] == folder.content_id
641
+        assert revision['is_archived'] is False
642
+        assert revision['is_deleted'] is False
643
+        assert revision['label'].startswith('test-folder-updated')
644
+        assert revision['parent_id'] is None
645
+        assert revision['show_in_ui'] is True
646
+        assert revision['slug'].startswith('test-folder-updated')
647
+        assert revision['status'] == 'open'
648
+        assert revision['workspace_id'] == test_workspace.workspace_id
649
+        assert revision['revision_id']
650
+        assert revision['revision_type'] == 'unarchiving'
651
+        assert revision['sub_content_types']
652
+        # TODO - G.M - 2018-06-173 - Test with real comments
653
+        assert revision['comment_ids'] == []
654
+        # TODO - G.M - 2018-06-173 - check date format
655
+        assert revision['created']
656
+        assert revision['author']
657
+        assert revision['author']['user_id'] == 1
658
+        assert revision['author']['avatar_url'] is None
659
+        assert revision['author']['public_name'] == 'Global manager'
660
+
661
+    def test_api__set_folder_status__ok_200__nominal_case(self) -> None:
662
+        """
663
+        Get one folder content
664
+        """
665
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
666
+        admin = dbsession.query(models.User) \
667
+            .filter(models.User.email == 'admin@admin.admin') \
668
+            .one()
669
+        workspace_api = WorkspaceApi(
670
+            current_user=admin,
671
+            session=dbsession,
672
+            config=self.app_config
673
+        )
674
+        content_api = ContentApi(
675
+            current_user=admin,
676
+            session=dbsession,
677
+            config=self.app_config
678
+        )
679
+        test_workspace = workspace_api.create_workspace(
680
+            label='test',
681
+            save_now=True,
682
+        )
683
+        folder = content_api.create(
684
+            label='test_folder',
685
+            content_type_slug=CONTENT_TYPES.Folder.slug,
686
+            workspace=test_workspace,
687
+            do_save=True,
688
+            do_notify=False
689
+        )
690
+        transaction.commit()
691
+        self.testapp.authorization = (
692
+            'Basic',
693
+            (
694
+                'admin@admin.admin',
695
+                'admin@admin.admin'
696
+            )
697
+        )
698
+        params = {
699
+            'status': 'closed-deprecated',
700
+        }
701
+
702
+        # before
703
+        res = self.testapp.get(
704
+            '/api/v2/workspaces/{workspace_id}/folders/{content_id}'.format(  # nopep8
705
+                # nopep8
706
+                workspace_id=test_workspace.workspace_id,
707
+                content_id=folder.content_id,
708
+            ),
709
+            status=200
710
+        )
711
+        content = res.json_body
712
+        assert content['content_type'] == 'folder'
713
+        assert content['content_id'] == folder.content_id
714
+        assert content['status'] == 'open'
715
+
716
+        # set status
717
+        self.testapp.put_json(
718
+            '/api/v2/workspaces/{workspace_id}/folders/{content_id}/status'.format(  # nopep8
719
+                workspace_id=test_workspace.workspace_id,
720
+                content_id=folder.content_id,
721
+            ),
722
+            params=params,
723
+            status=204
724
+        )
725
+
726
+        # after
727
+        res = self.testapp.get(
728
+            '/api/v2/workspaces/{workspace_id}/folders/{content_id}'.format(
729
+                workspace_id=test_workspace.workspace_id,
730
+                content_id=folder.content_id,
731
+            ),
732
+            status=200
733
+        )
734
+        content = res.json_body
735
+        assert content['content_type'] == 'folder'
736
+        assert content['content_id'] == folder.content_id
737
+        assert content['status'] == 'closed-deprecated'
738
+
739
+    def test_api__set_folder_status__err_400__wrong_status(self) -> None:
740
+        """
741
+        Get one folder content
742
+        """
743
+        self.testapp.authorization = (
744
+            'Basic',
745
+            (
746
+                'admin@admin.admin',
747
+                'admin@admin.admin'
748
+            )
749
+        )
750
+        params = {
751
+            'status': 'unexistant-status',
752
+        }
753
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
754
+        admin = dbsession.query(models.User) \
755
+            .filter(models.User.email == 'admin@admin.admin') \
756
+            .one()
757
+        workspace_api = WorkspaceApi(
758
+            current_user=admin,
759
+            session=dbsession,
760
+            config=self.app_config
761
+        )
762
+        content_api = ContentApi(
763
+            current_user=admin,
764
+            session=dbsession,
765
+            config=self.app_config
766
+        )
767
+        test_workspace = workspace_api.create_workspace(
768
+            label='test',
769
+            save_now=True,
770
+        )
771
+        folder = content_api.create(
772
+            label='test_folder',
773
+            content_type_slug=CONTENT_TYPES.Folder.slug,
774
+            workspace=test_workspace,
775
+            do_save=True,
776
+            do_notify=False
777
+        )
778
+        transaction.commit()
779
+        self.testapp.put_json(
780
+            '/api/v2/workspaces/{workspace_id}/folders/{content_id}/status'.format(  # nopep8
781
+                workspace_id=test_workspace.workspace_id,
782
+                content_id=folder.content_id,
783
+            ),
784
+            params=params,
785
+            status=400
786
+        )
787
+
27 788
 
28 789
 class TestHtmlDocuments(FunctionalTest):
29 790
     """
@@ -116,6 +877,54 @@ class TestHtmlDocuments(FunctionalTest):
116 877
         assert content['last_modifier']['avatar_url'] is None
117 878
         assert content['raw_content'] == '<p>To cook a great Tiramisu, you need many ingredients.</p>'  # nopep8
118 879
 
880
+    def test_api__get_html_document__ok_200__archived_content(self) -> None:
881
+        """
882
+        Get one html document of a content
883
+        """
884
+        self.testapp.authorization = (
885
+            'Basic',
886
+            (
887
+                'admin@admin.admin',
888
+                'admin@admin.admin'
889
+            )
890
+        )
891
+        res = self.testapp.put_json(
892
+            '/api/v2/workspaces/2/contents/6/archive',
893
+            status=204
894
+        )
895
+        res = self.testapp.get(
896
+            '/api/v2/workspaces/2/html-documents/6',
897
+            status=200
898
+        )
899
+        content = res.json_body
900
+        assert content['content_type'] == 'html-document'
901
+        assert content['content_id'] == 6
902
+        assert content['is_archived'] is True
903
+
904
+    def test_api__get_html_document__ok_200__deleted_content(self) -> None:
905
+        """
906
+        Get one html document of a content
907
+        """
908
+        self.testapp.authorization = (
909
+            'Basic',
910
+            (
911
+                'admin@admin.admin',
912
+                'admin@admin.admin'
913
+            )
914
+        )
915
+        res = self.testapp.put_json(
916
+            '/api/v2/workspaces/2/contents/6/delete',
917
+            status=204
918
+        )
919
+        res = self.testapp.get(
920
+            '/api/v2/workspaces/2/html-documents/6',
921
+            status=200
922
+        )
923
+        content = res.json_body
924
+        assert content['content_type'] == 'html-document'
925
+        assert content['content_id'] == 6
926
+        assert content['is_deleted'] is True
927
+
119 928
     def test_api__get_html_document__err_400__wrong_content_type(self) -> None:
120 929
         """
121 930
         Get one html document of a content content 7 is not html_document

+ 410 - 1
backend/tracim_backend/tests/functional/test_user.py View File

@@ -4,6 +4,7 @@ Tests for /api/v2/users subpath endpoints.
4 4
 """
5 5
 from time import sleep
6 6
 import pytest
7
+import requests
7 8
 import transaction
8 9
 
9 10
 from tracim_backend import models
@@ -2400,6 +2401,8 @@ class TestUserWorkspaceEndpoint(FunctionalTest):
2400 2401
         assert workspace['workspace_id'] == 1
2401 2402
         assert workspace['label'] == 'Business'
2402 2403
         assert workspace['slug'] == 'business'
2404
+        assert workspace['is_deleted'] is False
2405
+
2403 2406
         assert len(workspace['sidebar_entries']) == len(default_sidebar_entry)
2404 2407
         for counter, sidebar_entry in enumerate(default_sidebar_entry):
2405 2408
             workspace['sidebar_entries'][counter]['slug'] = sidebar_entry.slug
@@ -2408,7 +2411,6 @@ class TestUserWorkspaceEndpoint(FunctionalTest):
2408 2411
             workspace['sidebar_entries'][counter]['hexcolor'] = sidebar_entry.hexcolor  # nopep8
2409 2412
             workspace['sidebar_entries'][counter]['fa_icon'] = sidebar_entry.fa_icon  # nopep8
2410 2413
 
2411
-
2412 2414
     def test_api__get_user_workspaces__err_403__unallowed_user(self):
2413 2415
         """
2414 2416
         Check obtain all workspaces reachables for one user
@@ -2520,6 +2522,7 @@ class TestUserEndpoint(FunctionalTest):
2520 2522
         assert res['email'] == 'test@test.test'
2521 2523
         assert res['public_name'] == 'bob'
2522 2524
         assert res['timezone'] == 'Europe/Paris'
2525
+        assert res['is_deleted'] is False
2523 2526
 
2524 2527
     def test_api__get_user__ok_200__user_itself(self):
2525 2528
         dbsession = get_tm_session(self.session_factory, transaction.manager)
@@ -2569,6 +2572,7 @@ class TestUserEndpoint(FunctionalTest):
2569 2572
         assert res['email'] == 'test@test.test'
2570 2573
         assert res['public_name'] == 'bob'
2571 2574
         assert res['timezone'] == 'Europe/Paris'
2575
+        assert res['is_deleted'] is False
2572 2576
 
2573 2577
     def test_api__get_user__err_403__other_normal_user(self):
2574 2578
         dbsession = get_tm_session(self.session_factory, transaction.manager)
@@ -2621,6 +2625,349 @@ class TestUserEndpoint(FunctionalTest):
2621 2625
             status=403
2622 2626
         )
2623 2627
 
2628
+    def test_api__create_user__ok_200__full_admin(self):
2629
+        self.testapp.authorization = (
2630
+            'Basic',
2631
+            (
2632
+                'admin@admin.admin',
2633
+                'admin@admin.admin'
2634
+            )
2635
+        )
2636
+        params = {
2637
+            'email': 'test@test.test',
2638
+            'password': 'mysuperpassword',
2639
+            'profile': 'users',
2640
+            'timezone': 'Europe/Paris',
2641
+            'public_name': 'test user',
2642
+            'email_notification': False,
2643
+        }
2644
+        res = self.testapp.post_json(
2645
+            '/api/v2/users',
2646
+            status=200,
2647
+            params=params,
2648
+        )
2649
+        res = res.json_body
2650
+        assert res['user_id']
2651
+        user_id = res['user_id']
2652
+        assert res['created']
2653
+        assert res['is_active'] is True
2654
+        assert res['profile'] == 'users'
2655
+        assert res['email'] == 'test@test.test'
2656
+        assert res['public_name'] == 'test user'
2657
+        assert res['timezone'] == 'Europe/Paris'
2658
+
2659
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
2660
+        admin = dbsession.query(models.User) \
2661
+            .filter(models.User.email == 'admin@admin.admin') \
2662
+            .one()
2663
+        uapi = UserApi(
2664
+            current_user=admin,
2665
+            session=dbsession,
2666
+            config=self.app_config,
2667
+        )
2668
+        user = uapi.get_one(user_id)
2669
+        assert user.email == 'test@test.test'
2670
+        assert user.validate_password('mysuperpassword')
2671
+
2672
+    def test_api__create_user__ok_200__limited_admin(self):
2673
+        self.testapp.authorization = (
2674
+            'Basic',
2675
+            (
2676
+                'admin@admin.admin',
2677
+                'admin@admin.admin'
2678
+            )
2679
+        )
2680
+        params = {
2681
+            'email': 'test@test.test',
2682
+            'email_notification': False,
2683
+        }
2684
+        res = self.testapp.post_json(
2685
+            '/api/v2/users',
2686
+            status=200,
2687
+            params=params,
2688
+        )
2689
+        res = res.json_body
2690
+        assert res['user_id']
2691
+        user_id = res['user_id']
2692
+        assert res['created']
2693
+        assert res['is_active'] is True
2694
+        assert res['profile'] == 'users'
2695
+        assert res['email'] == 'test@test.test'
2696
+        assert res['public_name'] == 'test'
2697
+        assert res['timezone'] == ''
2698
+
2699
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
2700
+        admin = dbsession.query(models.User) \
2701
+            .filter(models.User.email == 'admin@admin.admin') \
2702
+            .one()
2703
+        uapi = UserApi(
2704
+            current_user=admin,
2705
+            session=dbsession,
2706
+            config=self.app_config,
2707
+        )
2708
+        user = uapi.get_one(user_id)
2709
+        assert user.email == 'test@test.test'
2710
+        assert user.password
2711
+
2712
+    def test_api__create_user__err_400__email_already_in_db(self):
2713
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
2714
+        admin = dbsession.query(models.User) \
2715
+            .filter(models.User.email == 'admin@admin.admin') \
2716
+            .one()
2717
+        uapi = UserApi(
2718
+            current_user=admin,
2719
+            session=dbsession,
2720
+            config=self.app_config,
2721
+        )
2722
+        gapi = GroupApi(
2723
+            current_user=admin,
2724
+            session=dbsession,
2725
+            config=self.app_config,
2726
+        )
2727
+        groups = [gapi.get_one_with_name('users')]
2728
+        test_user = uapi.create_user(
2729
+            email='test@test.test',
2730
+            password='pass',
2731
+            name='bob',
2732
+            groups=groups,
2733
+            timezone='Europe/Paris',
2734
+            do_save=True,
2735
+            do_notify=False,
2736
+        )
2737
+        uapi.save(test_user)
2738
+        transaction.commit()
2739
+        self.testapp.authorization = (
2740
+            'Basic',
2741
+            (
2742
+                'admin@admin.admin',
2743
+                'admin@admin.admin'
2744
+            )
2745
+        )
2746
+        params = {
2747
+            'email': 'test@test.test',
2748
+            'password': 'mysuperpassword',
2749
+            'profile': 'users',
2750
+            'timezone': 'Europe/Paris',
2751
+            'public_name': 'test user',
2752
+            'email_notification': False,
2753
+        }
2754
+        res = self.testapp.post_json(
2755
+            '/api/v2/users',
2756
+            status=400,
2757
+            params=params,
2758
+        )
2759
+
2760
+    def test_api__create_user__err_403__other_user(self):
2761
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
2762
+        admin = dbsession.query(models.User) \
2763
+            .filter(models.User.email == 'admin@admin.admin') \
2764
+            .one()
2765
+        uapi = UserApi(
2766
+            current_user=admin,
2767
+            session=dbsession,
2768
+            config=self.app_config,
2769
+        )
2770
+        gapi = GroupApi(
2771
+            current_user=admin,
2772
+            session=dbsession,
2773
+            config=self.app_config,
2774
+        )
2775
+        groups = [gapi.get_one_with_name('users')]
2776
+        test_user = uapi.create_user(
2777
+            email='test@test.test',
2778
+            password='pass',
2779
+            name='bob',
2780
+            groups=groups,
2781
+            timezone='Europe/Paris',
2782
+            do_save=True,
2783
+            do_notify=False,
2784
+        )
2785
+        uapi.save(test_user)
2786
+        transaction.commit()
2787
+        self.testapp.authorization = (
2788
+            'Basic',
2789
+            (
2790
+                'test@test.test',
2791
+                'pass',
2792
+            )
2793
+        )
2794
+        params = {
2795
+            'email': 'test2@test2.test2',
2796
+            'password': 'mysuperpassword',
2797
+            'profile': 'users',
2798
+            'timezone': 'Europe/Paris',
2799
+            'public_name': 'test user',
2800
+            'email_notification': False,
2801
+        }
2802
+        res = self.testapp.post_json(
2803
+            '/api/v2/users',
2804
+            status=403,
2805
+            params=params,
2806
+        )
2807
+
2808
+
2809
+class TestUserWithNotificationEndpoint(FunctionalTest):
2810
+    """
2811
+    Tests for POST /api/v2/users/{user_id}
2812
+    """
2813
+    config_section = 'functional_test_with_mail_test_sync'
2814
+
2815
+    def test_api__create_user__ok_200__full_admin_with_notif(self):
2816
+        requests.delete('http://127.0.0.1:8025/api/v1/messages')
2817
+        self.testapp.authorization = (
2818
+            'Basic',
2819
+            (
2820
+                'admin@admin.admin',
2821
+                'admin@admin.admin'
2822
+            )
2823
+        )
2824
+        params = {
2825
+            'email': 'test@test.test',
2826
+            'password': 'mysuperpassword',
2827
+            'profile': 'users',
2828
+            'timezone': 'Europe/Paris',
2829
+            'public_name': 'test user',
2830
+            'email_notification': True,
2831
+        }
2832
+        res = self.testapp.post_json(
2833
+            '/api/v2/users',
2834
+            status=200,
2835
+            params=params,
2836
+        )
2837
+        res = res.json_body
2838
+        assert res['user_id']
2839
+        user_id = res['user_id']
2840
+        assert res['created']
2841
+        assert res['is_active'] is True
2842
+        assert res['profile'] == 'users'
2843
+        assert res['email'] == 'test@test.test'
2844
+        assert res['public_name'] == 'test user'
2845
+        assert res['timezone'] == 'Europe/Paris'
2846
+
2847
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
2848
+        admin = dbsession.query(models.User) \
2849
+            .filter(models.User.email == 'admin@admin.admin') \
2850
+            .one()
2851
+        uapi = UserApi(
2852
+            current_user=admin,
2853
+            session=dbsession,
2854
+            config=self.app_config,
2855
+        )
2856
+        user = uapi.get_one(user_id)
2857
+        assert user.email == 'test@test.test'
2858
+        assert user.validate_password('mysuperpassword')
2859
+
2860
+        # check mail received
2861
+        response = requests.get('http://127.0.0.1:8025/api/v1/messages')
2862
+        response = response.json()
2863
+        assert len(response) == 1
2864
+        headers = response[0]['Content']['Headers']
2865
+        assert headers['From'][0] == 'Tracim Notifications <test_user_from+0@localhost>'  # nopep8
2866
+        assert headers['To'][0] == 'test user <test@test.test>'
2867
+        assert headers['Subject'][0] == '[TRACIM] Created account'
2868
+
2869
+        # TODO - G.M - 2018-08-02 - Place cleanup outside of the test
2870
+        requests.delete('http://127.0.0.1:8025/api/v1/messages')
2871
+
2872
+    def test_api__create_user__ok_200__limited_admin_with_notif(self):
2873
+        requests.delete('http://127.0.0.1:8025/api/v1/messages')
2874
+        self.testapp.authorization = (
2875
+            'Basic',
2876
+            (
2877
+                'admin@admin.admin',
2878
+                'admin@admin.admin'
2879
+            )
2880
+        )
2881
+        params = {
2882
+            'email': 'test@test.test',
2883
+            'email_notification': True,
2884
+        }
2885
+        res = self.testapp.post_json(
2886
+            '/api/v2/users',
2887
+            status=200,
2888
+            params=params,
2889
+        )
2890
+        res = res.json_body
2891
+        assert res['user_id']
2892
+        user_id = res['user_id']
2893
+        assert res['created']
2894
+        assert res['is_active'] is True
2895
+        assert res['profile'] == 'users'
2896
+        assert res['email'] == 'test@test.test'
2897
+        assert res['public_name'] == 'test'
2898
+        assert res['timezone'] == ''
2899
+
2900
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
2901
+        admin = dbsession.query(models.User) \
2902
+            .filter(models.User.email == 'admin@admin.admin') \
2903
+            .one()
2904
+        uapi = UserApi(
2905
+            current_user=admin,
2906
+            session=dbsession,
2907
+            config=self.app_config,
2908
+        )
2909
+        user = uapi.get_one(user_id)
2910
+        assert user.email == 'test@test.test'
2911
+        assert user.password
2912
+
2913
+        # check mail received
2914
+        response = requests.get('http://127.0.0.1:8025/api/v1/messages')
2915
+        response = response.json()
2916
+        assert len(response) == 1
2917
+        headers = response[0]['Content']['Headers']
2918
+        assert headers['From'][0] == 'Tracim Notifications <test_user_from+0@localhost>'  # nopep8
2919
+        assert headers['To'][0] == 'test <test@test.test>'
2920
+        assert headers['Subject'][0] == '[TRACIM] Created account'
2921
+
2922
+        # TODO - G.M - 2018-08-02 - Place cleanup outside of the test
2923
+        requests.delete('http://127.0.0.1:8025/api/v1/messages')
2924
+
2925
+    def test_api_delete_user__ok_200__admin(self):
2926
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
2927
+        admin = dbsession.query(models.User) \
2928
+            .filter(models.User.email == 'admin@admin.admin') \
2929
+            .one()
2930
+        uapi = UserApi(
2931
+            current_user=admin,
2932
+            session=dbsession,
2933
+            config=self.app_config,
2934
+        )
2935
+        gapi = GroupApi(
2936
+            current_user=admin,
2937
+            session=dbsession,
2938
+            config=self.app_config,
2939
+        )
2940
+        groups = [gapi.get_one_with_name('users')]
2941
+        test_user = uapi.create_user(
2942
+            email='test@test.test',
2943
+            password='pass',
2944
+            name='bob',
2945
+            groups=groups,
2946
+            timezone='Europe/Paris',
2947
+            do_save=True,
2948
+            do_notify=False,
2949
+        )
2950
+        uapi.save(test_user)
2951
+        transaction.commit()
2952
+        user_id = int(test_user.user_id)
2953
+
2954
+        self.testapp.authorization = (
2955
+            'Basic',
2956
+            (
2957
+                'admin@admin.admin',
2958
+                'admin@admin.admin'
2959
+            )
2960
+        )
2961
+        self.testapp.put(
2962
+            '/api/v2/users/{}/delete'.format(user_id),
2963
+            status=204
2964
+        )
2965
+        res = self.testapp.get(
2966
+            '/api/v2/users/{}'.format(user_id),
2967
+            status=200
2968
+        ).json_body
2969
+        assert res['is_deleted'] is True
2970
+
2624 2971
 
2625 2972
 class TestUsersEndpoint(FunctionalTest):
2626 2973
     # -*- coding: utf-8 -*-
@@ -3075,6 +3422,68 @@ class TestSetEmailEndpoint(FunctionalTest):
3075 3422
         res = res.json_body
3076 3423
         assert res['email'] == 'mysuperemail@email.fr'
3077 3424
 
3425
+    def test_api__set_user_email__err_400__admin_same_email(self):
3426
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
3427
+        admin = dbsession.query(models.User) \
3428
+            .filter(models.User.email == 'admin@admin.admin') \
3429
+            .one()
3430
+        uapi = UserApi(
3431
+            current_user=admin,
3432
+            session=dbsession,
3433
+            config=self.app_config,
3434
+        )
3435
+        gapi = GroupApi(
3436
+            current_user=admin,
3437
+            session=dbsession,
3438
+            config=self.app_config,
3439
+        )
3440
+        groups = [gapi.get_one_with_name('users')]
3441
+        test_user = uapi.create_user(
3442
+            email='test@test.test',
3443
+            password='pass',
3444
+            name='bob',
3445
+            groups=groups,
3446
+            timezone='Europe/Paris',
3447
+            do_save=True,
3448
+            do_notify=False,
3449
+        )
3450
+        uapi.save(test_user)
3451
+        transaction.commit()
3452
+        user_id = int(test_user.user_id)
3453
+
3454
+        self.testapp.authorization = (
3455
+            'Basic',
3456
+            (
3457
+                'admin@admin.admin',
3458
+                'admin@admin.admin'
3459
+            )
3460
+        )
3461
+        # check before
3462
+        res = self.testapp.get(
3463
+            '/api/v2/users/{}'.format(user_id),
3464
+            status=200
3465
+        )
3466
+        res = res.json_body
3467
+        assert res['email'] == 'test@test.test'
3468
+
3469
+        # Set password
3470
+        params = {
3471
+            'email': 'admin@admin.admin',
3472
+            'loggedin_user_password': 'admin@admin.admin',
3473
+        }
3474
+        self.testapp.put_json(
3475
+            '/api/v2/users/{}/email'.format(user_id),
3476
+            params=params,
3477
+            status=400,
3478
+        )
3479
+        # Check After
3480
+        res = self.testapp.get(
3481
+            '/api/v2/users/{}'.format(user_id),
3482
+            status=200
3483
+        )
3484
+        res = res.json_body
3485
+        assert res['email'] == 'test@test.test'
3486
+
3078 3487
     def test_api__set_user_email__err_403__admin_wrong_password(self):
3079 3488
         dbsession = get_tm_session(self.session_factory, transaction.manager)
3080 3489
         admin = dbsession.query(models.User) \

File diff suppressed because it is too large
+ 816 - 22
backend/tracim_backend/tests/functional/test_workspaces.py


+ 401 - 0
backend/tracim_backend/tests/library/test_content_api.py View File

@@ -10,6 +10,8 @@ from tracim_backend.lib.core.content import ContentApi
10 10
 from tracim_backend.lib.core.group import GroupApi
11 11
 from tracim_backend.lib.core.user import UserApi
12 12
 from tracim_backend.exceptions import SameValueError
13
+from tracim_backend.exceptions import EmptyLabelNotAllowed
14
+from tracim_backend.exceptions import UnallowedSubContent
13 15
 # TODO - G.M - 28-03-2018 - [RoleApi] Re-enable RoleApi
14 16
 from tracim_backend.lib.core.workspace import RoleApi
15 17
 # TODO - G.M - 28-03-2018 - [WorkspaceApi] Re-enable WorkspaceApi
@@ -101,6 +103,310 @@ class TestContentApi(DefaultTest):
101 103
             'value is {} instead of {}'.format(sorteds[1].content_id,
102 104
                                                c1.content_id))
103 105
 
106
+    def test_unit__create_content__OK_nominal_case(self):
107
+        uapi = UserApi(
108
+            session=self.session,
109
+            config=self.app_config,
110
+            current_user=None,
111
+        )
112
+        group_api = GroupApi(
113
+            current_user=None,
114
+            session=self.session,
115
+            config=self.app_config,
116
+        )
117
+        groups = [group_api.get_one(Group.TIM_USER),
118
+                  group_api.get_one(Group.TIM_MANAGER),
119
+                  group_api.get_one(Group.TIM_ADMIN)]
120
+
121
+        user = uapi.create_minimal_user(email='this.is@user',
122
+                                        groups=groups, save_now=True)
123
+        workspace = WorkspaceApi(
124
+            current_user=user,
125
+            session=self.session,
126
+            config=self.app_config,
127
+        ).create_workspace('test workspace', save_now=True)
128
+        api = ContentApi(
129
+            current_user=user,
130
+            session=self.session,
131
+            config=self.app_config,
132
+        )
133
+        item = api.create(
134
+            content_type_slug=CONTENT_TYPES.Folder.slug,
135
+            workspace=workspace,
136
+            parent=None,
137
+            label='not_deleted',
138
+            do_save=True
139
+        )
140
+        assert isinstance(item, Content)
141
+
142
+    def test_unit__create_content__err_empty_label(self):
143
+        uapi = UserApi(
144
+            session=self.session,
145
+            config=self.app_config,
146
+            current_user=None,
147
+        )
148
+        group_api = GroupApi(
149
+            current_user=None,
150
+            session=self.session,
151
+            config=self.app_config,
152
+        )
153
+        groups = [group_api.get_one(Group.TIM_USER),
154
+                  group_api.get_one(Group.TIM_MANAGER),
155
+                  group_api.get_one(Group.TIM_ADMIN)]
156
+
157
+        user = uapi.create_minimal_user(email='this.is@user',
158
+                                        groups=groups, save_now=True)
159
+        workspace = WorkspaceApi(
160
+            current_user=user,
161
+            session=self.session,
162
+            config=self.app_config,
163
+        ).create_workspace('test workspace', save_now=True)
164
+        api = ContentApi(
165
+            current_user=user,
166
+            session=self.session,
167
+            config=self.app_config,
168
+        )
169
+        with pytest.raises(EmptyLabelNotAllowed):
170
+            api.create(
171
+                content_type_slug=CONTENT_TYPES.Thread.slug,
172
+                workspace=workspace,
173
+                parent=None,
174
+                label='',
175
+                do_save=True
176
+            )
177
+
178
+    def test_unit__create_content__err_content_type_not_allowed_in_this_folder(self):
179
+        uapi = UserApi(
180
+            session=self.session,
181
+            config=self.app_config,
182
+            current_user=None,
183
+        )
184
+        group_api = GroupApi(
185
+            current_user=None,
186
+            session=self.session,
187
+            config=self.app_config,
188
+        )
189
+        groups = [group_api.get_one(Group.TIM_USER),
190
+                  group_api.get_one(Group.TIM_MANAGER),
191
+                  group_api.get_one(Group.TIM_ADMIN)]
192
+
193
+        user = uapi.create_minimal_user(email='this.is@user',
194
+                                        groups=groups, save_now=True)
195
+        workspace = WorkspaceApi(
196
+            current_user=user,
197
+            session=self.session,
198
+            config=self.app_config,
199
+        ).create_workspace('test workspace', save_now=True)
200
+        api = ContentApi(
201
+            current_user=user,
202
+            session=self.session,
203
+            config=self.app_config,
204
+        )
205
+        folder = api.create(
206
+            content_type_slug=CONTENT_TYPES.Folder.slug,
207
+            workspace=workspace,
208
+            parent=None,
209
+            label='plop',
210
+            do_save=False
211
+        )
212
+        allowed_content_dict = {CONTENT_TYPES.Folder.slug: True, CONTENT_TYPES.File.slug: False} # nopep8
213
+        api._set_allowed_content(
214
+            folder,
215
+            allowed_content_dict=allowed_content_dict
216
+        )
217
+        api.save(content=folder)
218
+        # not in list -> do not allow
219
+        with pytest.raises(UnallowedSubContent):
220
+            api.create(
221
+                content_type_slug=CONTENT_TYPES.Event.slug,
222
+                workspace=workspace,
223
+                parent=folder,
224
+                label='lapin',
225
+                do_save=True
226
+            )
227
+        # in list but false -> do not allow
228
+        with pytest.raises(UnallowedSubContent):
229
+            api.create(
230
+                content_type_slug=CONTENT_TYPES.File.slug,
231
+                workspace=workspace,
232
+                parent=folder,
233
+                label='lapin',
234
+                do_save=True
235
+            )
236
+        # in list and true -> allow
237
+        api.create(
238
+            content_type_slug=CONTENT_TYPES.Folder.slug,
239
+            workspace=workspace,
240
+            parent=folder,
241
+            label='lapin',
242
+            do_save=True
243
+        )
244
+
245
+    def test_unit__create_content__err_content_type_not_allowed_in_this_workspace(self):
246
+        uapi = UserApi(
247
+            session=self.session,
248
+            config=self.app_config,
249
+            current_user=None,
250
+        )
251
+        group_api = GroupApi(
252
+            current_user=None,
253
+            session=self.session,
254
+            config=self.app_config,
255
+        )
256
+        groups = [group_api.get_one(Group.TIM_USER),
257
+                  group_api.get_one(Group.TIM_MANAGER),
258
+                  group_api.get_one(Group.TIM_ADMIN)]
259
+
260
+        user = uapi.create_minimal_user(email='this.is@user',
261
+                                        groups=groups, save_now=True)
262
+        workspace = WorkspaceApi(
263
+            current_user=user,
264
+            session=self.session,
265
+            config=self.app_config,
266
+        ).create_workspace('test workspace', save_now=True)
267
+        api = ContentApi(
268
+            current_user=user,
269
+            session=self.session,
270
+            config=self.app_config,
271
+        )
272
+        with pytest.raises(UnallowedSubContent):
273
+            api.create(
274
+                content_type_slug=CONTENT_TYPES.Event.slug,
275
+                workspace=workspace,
276
+                parent=None,
277
+                label='lapin',
278
+                do_save=True
279
+           )
280
+
281
+    def test_unit__set_allowed_content__ok__private_method(self):
282
+        uapi = UserApi(
283
+            session=self.session,
284
+            config=self.app_config,
285
+            current_user=None,
286
+        )
287
+        group_api = GroupApi(
288
+            current_user=None,
289
+            session=self.session,
290
+            config=self.app_config,
291
+        )
292
+        groups = [group_api.get_one(Group.TIM_USER),
293
+                  group_api.get_one(Group.TIM_MANAGER),
294
+                  group_api.get_one(Group.TIM_ADMIN)]
295
+
296
+        user = uapi.create_minimal_user(email='this.is@user',
297
+                                        groups=groups, save_now=True)
298
+        workspace = WorkspaceApi(
299
+            current_user=user,
300
+            session=self.session,
301
+            config=self.app_config,
302
+        ).create_workspace('test workspace', save_now=True)
303
+        api = ContentApi(
304
+            current_user=user,
305
+            session=self.session,
306
+            config=self.app_config,
307
+        )
308
+        folder = api.create(
309
+            content_type_slug=CONTENT_TYPES.Folder.slug,
310
+            workspace=workspace,
311
+            parent=None,
312
+            label='plop',
313
+            do_save=False
314
+        )
315
+        allowed_content_dict = {CONTENT_TYPES.Folder.slug: True, CONTENT_TYPES.File.slug: False}  # nopep8
316
+        api._set_allowed_content(
317
+            folder,
318
+            allowed_content_dict=allowed_content_dict
319
+        )
320
+        assert 'allowed_content' in folder.properties
321
+        assert folder.properties['allowed_content'] == {CONTENT_TYPES.Folder.slug: True, CONTENT_TYPES.File.slug: False}
322
+
323
+    def test_unit__set_allowed_content__ok__nominal_case(self):
324
+        uapi = UserApi(
325
+            session=self.session,
326
+            config=self.app_config,
327
+            current_user=None,
328
+        )
329
+        group_api = GroupApi(
330
+            current_user=None,
331
+            session=self.session,
332
+            config=self.app_config,
333
+        )
334
+        groups = [group_api.get_one(Group.TIM_USER),
335
+                  group_api.get_one(Group.TIM_MANAGER),
336
+                  group_api.get_one(Group.TIM_ADMIN)]
337
+
338
+        user = uapi.create_minimal_user(email='this.is@user',
339
+                                        groups=groups, save_now=True)
340
+        workspace = WorkspaceApi(
341
+            current_user=user,
342
+            session=self.session,
343
+            config=self.app_config,
344
+        ).create_workspace('test workspace', save_now=True)
345
+        api = ContentApi(
346
+            current_user=user,
347
+            session=self.session,
348
+            config=self.app_config,
349
+        )
350
+        folder = api.create(
351
+            content_type_slug=CONTENT_TYPES.Folder.slug,
352
+            workspace=workspace,
353
+            parent=None,
354
+            label='plop',
355
+            do_save=False
356
+        )
357
+        allowed_content_type_slug_list = [CONTENT_TYPES.Folder.slug, CONTENT_TYPES.File.slug]  # nopep8
358
+        api.set_allowed_content(
359
+            folder,
360
+            allowed_content_type_slug_list=allowed_content_type_slug_list
361
+        )
362
+        assert 'allowed_content' in folder.properties
363
+        assert folder.properties['allowed_content'] == {CONTENT_TYPES.Folder.slug: True, CONTENT_TYPES.File.slug: True}
364
+
365
+    def test_unit__restore_content_default_allowed_content__ok__nominal_case(self):
366
+        uapi = UserApi(
367
+            session=self.session,
368
+            config=self.app_config,
369
+            current_user=None,
370
+        )
371
+        group_api = GroupApi(
372
+            current_user=None,
373
+            session=self.session,
374
+            config=self.app_config,
375
+        )
376
+        groups = [group_api.get_one(Group.TIM_USER),
377
+                  group_api.get_one(Group.TIM_MANAGER),
378
+                  group_api.get_one(Group.TIM_ADMIN)]
379
+
380
+        user = uapi.create_minimal_user(email='this.is@user',
381
+                                        groups=groups, save_now=True)
382
+        workspace = WorkspaceApi(
383
+            current_user=user,
384
+            session=self.session,
385
+            config=self.app_config,
386
+        ).create_workspace('test workspace', save_now=True)
387
+        api = ContentApi(
388
+            current_user=user,
389
+            session=self.session,
390
+            config=self.app_config,
391
+        )
392
+        folder = api.create(
393
+            content_type_slug=CONTENT_TYPES.Folder.slug,
394
+            workspace=workspace,
395
+            parent=None,
396
+            label='plop',
397
+            do_save=False
398
+        )
399
+        allowed_content_type_slug_list = [CONTENT_TYPES.Folder.slug, CONTENT_TYPES.File.slug]  # nopep8
400
+        api.set_allowed_content(
401
+            folder,
402
+            allowed_content_type_slug_list=allowed_content_type_slug_list
403
+        )
404
+        assert 'allowed_content' in folder.properties
405
+        assert folder.properties['allowed_content'] == {CONTENT_TYPES.Folder.slug: True, CONTENT_TYPES.File.slug: True} # nopep8
406
+        api.restore_content_default_allowed_content(folder)
407
+        assert 'allowed_content' in folder.properties
408
+        assert folder.properties['allowed_content'] == CONTENT_TYPES.default_allowed_content_properties(folder.type)  # nopep8
409
+
104 410
     def test_delete(self):
105 411
         uapi = UserApi(
106 412
             session=self.session,
@@ -2057,6 +2363,101 @@ class TestContentApi(DefaultTest):
2057 2363
         # (workspace2)
2058 2364
         assert last_actives[8] == main_folder_workspace2
2059 2365
 
2366
+    def test_unit__get_last_active__ok__do_no_show_deleted_archived(self):
2367
+        uapi = UserApi(
2368
+            session=self.session,
2369
+            config=self.app_config,
2370
+            current_user=None,
2371
+        )
2372
+        group_api = GroupApi(
2373
+            current_user=None,
2374
+            session=self.session,
2375
+            config=self.app_config,
2376
+        )
2377
+        groups = [group_api.get_one(Group.TIM_USER),
2378
+                  group_api.get_one(Group.TIM_MANAGER),
2379
+                  group_api.get_one(Group.TIM_ADMIN)]
2380
+
2381
+        user = uapi.create_minimal_user(email='this.is@user',
2382
+                                        groups=groups, save_now=True)
2383
+        workspace = WorkspaceApi(
2384
+            current_user=user,
2385
+            session=self.session,
2386
+            config=self.app_config,
2387
+        ).create_workspace(
2388
+            'test workspace',
2389
+            save_now=True
2390
+        )
2391
+        workspace2 = WorkspaceApi(
2392
+            current_user=user,
2393
+            session=self.session,
2394
+            config=self.app_config,
2395
+        ).create_workspace(
2396
+            'test workspace2',
2397
+            save_now=True
2398
+        )
2399
+
2400
+        api = ContentApi(
2401
+            current_user=user,
2402
+            session=self.session,
2403
+            config=self.app_config,
2404
+            show_deleted=False,
2405
+            show_archived=False,
2406
+        )
2407
+        main_folder = api.create(CONTENT_TYPES.Folder.slug, workspace, None, 'this is randomized folder', '', True)  # nopep8
2408
+        archived = api.create(CONTENT_TYPES.Page.slug, workspace, main_folder, 'archived', '', True)  # nopep8
2409
+        deleted = api.create(CONTENT_TYPES.Page.slug, workspace, main_folder, 'deleted', '', True)  # nopep8
2410
+        comment_archived = api.create_comment(workspace, parent=archived, content='just a comment', do_save=True)  # nopep8
2411
+        comment_deleted = api.create_comment(workspace, parent=deleted, content='just a comment', do_save=True)  # nopep8
2412
+        with new_revision(
2413
+            session=self.session,
2414
+            tm=transaction.manager,
2415
+            content=archived,
2416
+        ):
2417
+            api.archive(archived)
2418
+            api.save(archived)
2419
+
2420
+        with new_revision(
2421
+            session=self.session,
2422
+            tm=transaction.manager,
2423
+            content=deleted,
2424
+        ):
2425
+            api.delete(deleted)
2426
+            api.save(deleted)
2427
+        normal = api.create(CONTENT_TYPES.Page.slug, workspace, main_folder, 'normal', '', True)  # nopep8
2428
+        comment_normal = api.create_comment(workspace, parent=normal, content='just a comment', do_save=True)  # nopep8
2429
+
2430
+        last_actives = api.get_last_active()
2431
+        assert len(last_actives) == 2
2432
+        assert last_actives[0].content_id == normal.content_id
2433
+        assert last_actives[1].content_id == main_folder.content_id
2434
+
2435
+
2436
+        api._show_deleted = True
2437
+        api._show_archived = False
2438
+        last_actives = api.get_last_active()
2439
+        assert len(last_actives) == 3
2440
+        assert last_actives[0] == normal
2441
+        assert last_actives[1] == deleted
2442
+        assert last_actives[2] == main_folder
2443
+
2444
+        api._show_deleted = False
2445
+        api._show_archived = True
2446
+        last_actives = api.get_last_active()
2447
+        assert len(last_actives) == 3
2448
+        assert last_actives[0]== normal
2449
+        assert last_actives[1] == archived
2450
+        assert last_actives[2] == main_folder
2451
+
2452
+        api._show_deleted = True
2453
+        api._show_archived = True
2454
+        last_actives = api.get_last_active()
2455
+        assert len(last_actives) == 4
2456
+        assert last_actives[0] == normal
2457
+        assert last_actives[1] == deleted
2458
+        assert last_actives[2] == archived
2459
+        assert last_actives[3] == main_folder
2460
+
2060 2461
     def test_unit__get_last_active__ok__workspace_filter_workspace_full(self):
2061 2462
         uapi = UserApi(
2062 2463
             session=self.session,

+ 198 - 0
backend/tracim_backend/views/contents_api/folder_controller.py View File

@@ -0,0 +1,198 @@
1
+# coding=utf-8
2
+import typing
3
+
4
+import transaction
5
+from pyramid.config import Configurator
6
+from tracim_backend.models.data import UserRoleInWorkspace
7
+
8
+try:  # Python 3.5+
9
+    from http import HTTPStatus
10
+except ImportError:
11
+    from http import client as HTTPStatus
12
+
13
+from tracim_backend import TracimRequest
14
+from tracim_backend.extensions import hapic
15
+from tracim_backend.lib.core.content import ContentApi
16
+from tracim_backend.views.controllers import Controller
17
+from tracim_backend.views.core_api.schemas import TextBasedContentSchema
18
+from tracim_backend.views.core_api.schemas import FolderContentModifySchema
19
+from tracim_backend.views.core_api.schemas import TextBasedRevisionSchema
20
+from tracim_backend.views.core_api.schemas import SetContentStatusSchema
21
+from tracim_backend.views.core_api.schemas import WorkspaceAndContentIdPathSchema  # nopep8
22
+from tracim_backend.views.core_api.schemas import NoContentSchema
23
+from tracim_backend.lib.utils.authorization import require_content_types
24
+from tracim_backend.lib.utils.authorization import require_workspace_role
25
+from tracim_backend.exceptions import EmptyLabelNotAllowed
26
+from tracim_backend.models.context_models import ContentInContext
27
+from tracim_backend.models.context_models import RevisionInContext
28
+from tracim_backend.app_models.contents import CONTENT_TYPES
29
+from tracim_backend.app_models.contents import folder_type
30
+from tracim_backend.models.revision_protection import new_revision
31
+
32
+SWAGGER_TAG__Folders_ENDPOINTS = 'Folders'
33
+
34
+
35
+class FolderController(Controller):
36
+
37
+    @hapic.with_api_doc(tags=[SWAGGER_TAG__Folders_ENDPOINTS])
38
+    @require_workspace_role(UserRoleInWorkspace.READER)
39
+    @require_content_types([folder_type])
40
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
41
+    @hapic.output_body(TextBasedContentSchema())
42
+    def get_folder(self, context, request: TracimRequest, hapic_data=None) -> ContentInContext:  # nopep8
43
+        """
44
+        Get folder info
45
+        """
46
+        app_config = request.registry.settings['CFG']
47
+        api = ContentApi(
48
+            show_archived=True,
49
+            show_deleted=True,
50
+            current_user=request.current_user,
51
+            session=request.dbsession,
52
+            config=app_config,
53
+        )
54
+        content = api.get_one(
55
+            hapic_data.path.content_id,
56
+            content_type=CONTENT_TYPES.Any_SLUG
57
+        )
58
+        return api.get_content_in_context(content)
59
+
60
+    @hapic.with_api_doc(tags=[SWAGGER_TAG__Folders_ENDPOINTS])
61
+    @hapic.handle_exception(EmptyLabelNotAllowed, HTTPStatus.BAD_REQUEST)
62
+    @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
63
+    @require_content_types([folder_type])
64
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
65
+    @hapic.input_body(FolderContentModifySchema())
66
+    @hapic.output_body(TextBasedContentSchema())
67
+    def update_folder(self, context, request: TracimRequest, hapic_data=None) -> ContentInContext:  # nopep8
68
+        """
69
+        update folder
70
+        """
71
+        app_config = request.registry.settings['CFG']
72
+        api = ContentApi(
73
+            show_archived=True,
74
+            show_deleted=True,
75
+            current_user=request.current_user,
76
+            session=request.dbsession,
77
+            config=app_config,
78
+        )
79
+        content = api.get_one(
80
+            hapic_data.path.content_id,
81
+            content_type=CONTENT_TYPES.Any_SLUG
82
+        )
83
+        with new_revision(
84
+                session=request.dbsession,
85
+                tm=transaction.manager,
86
+                content=content
87
+        ):
88
+            api.update_content(
89
+                item=content,
90
+                new_label=hapic_data.body.label,
91
+                new_content=hapic_data.body.raw_content,
92
+
93
+            )
94
+            api.set_allowed_content(
95
+                content=content,
96
+                allowed_content_type_slug_list=hapic_data.body.sub_content_types  # nopep8
97
+            )
98
+            api.save(content)
99
+        return api.get_content_in_context(content)
100
+
101
+    @hapic.with_api_doc(tags=[SWAGGER_TAG__Folders_ENDPOINTS])
102
+    @require_workspace_role(UserRoleInWorkspace.READER)
103
+    @require_content_types([folder_type])
104
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
105
+    @hapic.output_body(TextBasedRevisionSchema(many=True))
106
+    def get_folder_revisions(
107
+            self,
108
+            context,
109
+            request: TracimRequest,
110
+            hapic_data=None
111
+    ) -> typing.List[RevisionInContext]:
112
+        """
113
+        get folder revisions
114
+        """
115
+        app_config = request.registry.settings['CFG']
116
+        api = ContentApi(
117
+            show_archived=True,
118
+            show_deleted=True,
119
+            current_user=request.current_user,
120
+            session=request.dbsession,
121
+            config=app_config,
122
+        )
123
+        content = api.get_one(
124
+            hapic_data.path.content_id,
125
+            content_type=CONTENT_TYPES.Any_SLUG
126
+        )
127
+        revisions = content.revisions
128
+        return [
129
+            api.get_revision_in_context(revision)
130
+            for revision in revisions
131
+        ]
132
+
133
+    @hapic.with_api_doc(tags=[SWAGGER_TAG__Folders_ENDPOINTS])
134
+    @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
135
+    @require_content_types([folder_type])
136
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
137
+    @hapic.input_body(SetContentStatusSchema())
138
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
139
+    def set_folder_status(self, context, request: TracimRequest, hapic_data=None) -> None:  # nopep8
140
+        """
141
+        set folder status
142
+        """
143
+        app_config = request.registry.settings['CFG']
144
+        api = ContentApi(
145
+            show_archived=True,
146
+            show_deleted=True,
147
+            current_user=request.current_user,
148
+            session=request.dbsession,
149
+            config=app_config,
150
+        )
151
+        content = api.get_one(
152
+            hapic_data.path.content_id,
153
+            content_type=CONTENT_TYPES.Any_SLUG
154
+        )
155
+        with new_revision(
156
+                session=request.dbsession,
157
+                tm=transaction.manager,
158
+                content=content
159
+        ):
160
+            api.set_status(
161
+                content,
162
+                hapic_data.body.status,
163
+            )
164
+            api.save(content)
165
+        return
166
+
167
+    def bind(self, configurator: Configurator) -> None:
168
+        # Get folder
169
+        configurator.add_route(
170
+            'folder',
171
+            '/workspaces/{workspace_id}/folders/{content_id}',
172
+            request_method='GET'
173
+        )
174
+        configurator.add_view(self.get_folder, route_name='folder')  # nopep8
175
+
176
+        # update folder
177
+        configurator.add_route(
178
+            'update_folder',
179
+            '/workspaces/{workspace_id}/folders/{content_id}',
180
+            request_method='PUT'
181
+        )  # nopep8
182
+        configurator.add_view(self.update_folder, route_name='update_folder')  # nopep8
183
+
184
+        # get folder revisions
185
+        configurator.add_route(
186
+            'folder_revisions',
187
+            '/workspaces/{workspace_id}/folders/{content_id}/revisions',  # nopep8
188
+            request_method='GET'
189
+        )
190
+        configurator.add_view(self.get_folder_revisions, route_name='folder_revisions')  # nopep8
191
+
192
+        # get folder revisions
193
+        configurator.add_route(
194
+            'set_folder_status',
195
+            '/workspaces/{workspace_id}/folders/{content_id}/status',  # nopep8
196
+            request_method='PUT'
197
+        )
198
+        configurator.add_view(self.set_folder_status, route_name='set_folder_status')  # nopep8

+ 57 - 6
backend/tracim_backend/views/core_api/schemas.py View File

@@ -7,11 +7,14 @@ from marshmallow.validate import Range
7 7
 
8 8
 from tracim_backend.lib.utils.utils import DATETIME_FORMAT
9 9
 from tracim_backend.models.auth import Profile
10
+
10 11
 from tracim_backend.app_models.contents import GlobalStatus
11 12
 from tracim_backend.app_models.contents import CONTENT_STATUS
12 13
 from tracim_backend.app_models.contents import CONTENT_TYPES
13 14
 from tracim_backend.app_models.contents import open_status
15
+from tracim_backend.models.auth import Group
14 16
 from tracim_backend.models.context_models import ActiveContentFilter
17
+from tracim_backend.models.context_models import FolderContentUpdate
15 18
 from tracim_backend.models.context_models import AutocompleteQuery
16 19
 from tracim_backend.models.context_models import ContentIdsQuery
17 20
 from tracim_backend.models.context_models import UserWorkspaceAndContentPath
@@ -75,6 +78,10 @@ class UserSchema(UserDigestSchema):
75 78
         example=True,
76 79
         description='Is user account activated ?'
77 80
     )
81
+    is_deleted = marshmallow.fields.Bool(
82
+        example=False,
83
+        description='Is user account deleted ?'
84
+    )
78 85
     # TODO - G.M - 17-04-2018 - Restrict timezone values
79 86
     timezone = marshmallow.fields.String(
80 87
         example="Europe/Paris",
@@ -155,12 +162,38 @@ class UserProfileSchema(marshmallow.Schema):
155 162
         return UserProfile(**data)
156 163
 
157 164
 
158
-class UserCreationSchema(
159
-    SetEmailSchema,
160
-    SetPasswordSchema,
161
-    UserInfosSchema,
162
-    UserProfileSchema
163
-):
165
+class UserCreationSchema(marshmallow.Schema):
166
+    email = marshmallow.fields.Email(
167
+        required=True,
168
+        example='suri.cate@algoo.fr'
169
+    )
170
+    password = marshmallow.fields.String(
171
+        example='8QLa$<w',
172
+        required=False,
173
+    )
174
+    profile = marshmallow.fields.String(
175
+        attribute='profile',
176
+        validate=OneOf(Profile._NAME),
177
+        example='managers',
178
+        required=False,
179
+        default=Group.TIM_USER_GROUPNAME
180
+    )
181
+    timezone = marshmallow.fields.String(
182
+        example="Europe/Paris",
183
+        required=False,
184
+        default=''
185
+    )
186
+    public_name = marshmallow.fields.String(
187
+        example='Suri Cate',
188
+        required=False,
189
+        default=None,
190
+    )
191
+    email_notification = marshmallow.fields.Bool(
192
+        example=True,
193
+        required=False,
194
+        default=True,
195
+    )
196
+
164 197
     @post_load
165 198
     def create_user(self, data):
166 199
         return UserCreation(**data)
@@ -300,6 +333,7 @@ class AutocompleteQuerySchema(marshmallow.Schema):
300 333
         example='test',
301 334
         description='search text to query',
302 335
         validate=Length(min=2),
336
+        required=True,
303 337
     )
304 338
     @post_load
305 339
     def make_autocomplete(self, data):
@@ -510,6 +544,7 @@ class WorkspaceDigestSchema(marshmallow.Schema):
510 544
         WorkspaceMenuEntrySchema,
511 545
         many=True,
512 546
     )
547
+    is_deleted = marshmallow.fields.Bool(example=False, default=False)
513 548
 
514 549
     class Meta:
515 550
         description = 'Digest of workspace informations'
@@ -848,6 +883,22 @@ class TextBasedContentModifySchema(ContentModifyAbstractSchema, TextBasedDataAbs
848 883
         return TextBasedContentUpdate(**data)
849 884
 
850 885
 
886
+class FolderContentModifySchema(ContentModifyAbstractSchema, TextBasedDataAbstractSchema):  # nopep
887
+    sub_content_types = marshmallow.fields.List(
888
+        marshmallow.fields.String(
889
+            example='html-document',
890
+            validate=ALL_CONTENT_TYPES_VALIDATOR,
891
+        ),
892
+        description='list of content types allowed as sub contents. '
893
+                    'This field is required for folder contents, '
894
+                    'set it to empty list in other cases'
895
+    )
896
+
897
+    @post_load
898
+    def folder_content_update(self, data):
899
+        return FolderContentUpdate(**data)
900
+
901
+
851 902
 class FileContentModifySchema(TextBasedContentModifySchema):
852 903
     pass
853 904
 

+ 47 - 1
backend/tracim_backend/views/core_api/user_controller.py View File

@@ -1,4 +1,5 @@
1 1
 from pyramid.config import Configurator
2
+from tracim_backend.lib.utils.utils import password_generator
2 3
 
3 4
 try:  # Python 3.5+
4 5
     from http import HTTPStatus
@@ -16,6 +17,7 @@ from tracim_backend.views.controllers import Controller
16 17
 from tracim_backend.lib.utils.authorization import require_same_user_or_profile
17 18
 from tracim_backend.lib.utils.authorization import require_profile
18 19
 from tracim_backend.exceptions import WrongUserPassword
20
+from tracim_backend.exceptions import EmailAlreadyExistInDb
19 21
 from tracim_backend.exceptions import PasswordDoNotMatch
20 22
 from tracim_backend.views.core_api.schemas import UserSchema
21 23
 from tracim_backend.views.core_api.schemas import AutocompleteQuerySchema
@@ -120,6 +122,7 @@ class UserController(Controller):
120 122
 
121 123
     @hapic.with_api_doc(tags=[SWAGGER_TAG__USER_ENDPOINTS])
122 124
     @hapic.handle_exception(WrongUserPassword, HTTPStatus.FORBIDDEN)
125
+    @hapic.handle_exception(EmailAlreadyExistInDb, HTTPStatus.BAD_REQUEST)
123 126
     @require_same_user_or_profile(Group.TIM_ADMIN)
124 127
     @hapic.input_body(SetEmailSchema())
125 128
     @hapic.input_path(UserIdPathSchema())
@@ -192,8 +195,8 @@ class UserController(Controller):
192 195
         return uapi.get_user_with_context(user)
193 196
 
194 197
     @hapic.with_api_doc(tags=[SWAGGER_TAG__USER_ENDPOINTS])
198
+    @hapic.handle_exception(EmailAlreadyExistInDb, HTTPStatus.BAD_REQUEST)
195 199
     @require_profile(Group.TIM_ADMIN)
196
-    @hapic.input_path(UserIdPathSchema())
197 200
     @hapic.input_body(UserCreationSchema())
198 201
     @hapic.output_body(UserSchema())
199 202
     def create_user(self, context, request: TracimRequest, hapic_data=None):
@@ -244,6 +247,41 @@ class UserController(Controller):
244 247
     @require_profile(Group.TIM_ADMIN)
245 248
     @hapic.input_path(UserIdPathSchema())
246 249
     @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
250
+    def delete_user(self, context, request: TracimRequest, hapic_data=None):
251
+        """
252
+        delete user
253
+        """
254
+        app_config = request.registry.settings['CFG']
255
+        uapi = UserApi(
256
+            current_user=request.current_user,  # User
257
+            session=request.dbsession,
258
+            config=app_config,
259
+        )
260
+        uapi.delete(user=request.candidate_user, do_save=True)
261
+        return
262
+
263
+    @hapic.with_api_doc(tags=[SWAGGER_TAG__USER_ENDPOINTS])
264
+    @require_profile(Group.TIM_ADMIN)
265
+    @hapic.input_path(UserIdPathSchema())
266
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
267
+    def undelete_user(self, context, request: TracimRequest, hapic_data=None):
268
+        """
269
+        undelete user
270
+        """
271
+        app_config = request.registry.settings['CFG']
272
+        uapi = UserApi(
273
+            current_user=request.current_user,  # User
274
+            session=request.dbsession,
275
+            config=app_config,
276
+            show_deleted=True,
277
+        )
278
+        uapi.undelete(user=request.candidate_user, do_save=True)
279
+        return
280
+
281
+    @hapic.with_api_doc(tags=[SWAGGER_TAG__USER_ENDPOINTS])
282
+    @require_profile(Group.TIM_ADMIN)
283
+    @hapic.input_path(UserIdPathSchema())
284
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
247 285
     def disable_user(self, context, request: TracimRequest, hapic_data=None):
248 286
         """
249 287
         disable user
@@ -464,6 +502,14 @@ class UserController(Controller):
464 502
         configurator.add_route('disable_user', '/users/{user_id}/disable', request_method='PUT')  # nopep8
465 503
         configurator.add_view(self.disable_user, route_name='disable_user')
466 504
 
505
+        # delete user
506
+        configurator.add_route('delete_user', '/users/{user_id}/delete', request_method='PUT')  # nopep8
507
+        configurator.add_view(self.delete_user, route_name='delete_user')
508
+
509
+        # undelete user
510
+        configurator.add_route('undelete_user', '/users/{user_id}/undelete', request_method='PUT')  # nopep8
511
+        configurator.add_view(self.undelete_user, route_name='undelete_user')
512
+
467 513
         # set user profile
468 514
         configurator.add_route('set_user_profile', '/users/{user_id}/profile', request_method='PUT')  # nopep8
469 515
         configurator.add_view(self.set_profile, route_name='set_user_profile')

+ 147 - 1
backend/tracim_backend/views/core_api/workspace_controller.py View File

@@ -1,6 +1,7 @@
1 1
 import typing
2 2
 import transaction
3 3
 from pyramid.config import Configurator
4
+from pyramid.httpexceptions import HTTPFound
4 5
 
5 6
 from tracim_backend.lib.core.user import UserApi
6 7
 from tracim_backend.models.roles import WorkspaceRoles
@@ -12,10 +13,12 @@ except ImportError:
12 13
 
13 14
 from tracim_backend import hapic
14 15
 from tracim_backend.lib.utils.request import TracimRequest
16
+from tracim_backend import BASE_API_V2
15 17
 from tracim_backend.lib.core.workspace import WorkspaceApi
16 18
 from tracim_backend.lib.core.content import ContentApi
17 19
 from tracim_backend.lib.core.userworkspace import RoleApi
18 20
 from tracim_backend.lib.utils.authorization import require_workspace_role
21
+from tracim_backend.lib.utils.authorization import require_profile_or_other_profile_with_workspace_role
19 22
 from tracim_backend.lib.utils.authorization import require_profile
20 23
 from tracim_backend.models import Group
21 24
 from tracim_backend.lib.utils.authorization import require_candidate_workspace_role
@@ -24,6 +27,7 @@ from tracim_backend.models.data import ActionDescription
24 27
 from tracim_backend.models.context_models import UserRoleWorkspaceInContext
25 28
 from tracim_backend.models.context_models import ContentInContext
26 29
 from tracim_backend.exceptions import EmptyLabelNotAllowed
30
+from tracim_backend.exceptions import UnallowedSubContent
27 31
 from tracim_backend.exceptions import EmailValidationFailed
28 32
 from tracim_backend.exceptions import UserCreationFailed
29 33
 from tracim_backend.exceptions import UserDoesNotExist
@@ -33,6 +37,7 @@ from tracim_backend.exceptions import ParentNotFound
33 37
 from tracim_backend.views.controllers import Controller
34 38
 from tracim_backend.lib.utils.utils import password_generator
35 39
 from tracim_backend.views.core_api.schemas import FilterContentQuerySchema
40
+from tracim_backend.views.core_api.schemas import ContentIdPathSchema
36 41
 from tracim_backend.views.core_api.schemas import WorkspaceMemberCreationSchema
37 42
 from tracim_backend.views.core_api.schemas import WorkspaceMemberInviteSchema
38 43
 from tracim_backend.views.core_api.schemas import RoleUpdateSchema
@@ -118,6 +123,51 @@ class WorkspaceController(Controller):
118 123
         return wapi.get_workspace_with_context(workspace)
119 124
 
120 125
     @hapic.with_api_doc(tags=[SWAGGER_TAG_WORKSPACE_ENDPOINTS])
126
+    @hapic.handle_exception(EmptyLabelNotAllowed, HTTPStatus.BAD_REQUEST)
127
+    @require_profile_or_other_profile_with_workspace_role(
128
+        Group.TIM_ADMIN,
129
+        Group.TIM_MANAGER,
130
+        UserRoleInWorkspace.WORKSPACE_MANAGER,
131
+    )
132
+    @hapic.input_path(WorkspaceIdPathSchema())
133
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
134
+    def delete_workspace(self, context, request: TracimRequest, hapic_data=None):  # nopep8
135
+        """
136
+        delete workspace
137
+        """
138
+        app_config = request.registry.settings['CFG']
139
+        wapi = WorkspaceApi(
140
+            current_user=request.current_user,  # User
141
+            session=request.dbsession,
142
+            config=app_config,
143
+        )
144
+        wapi.delete(request.current_workspace, flush=True)
145
+        return
146
+
147
+    @hapic.with_api_doc(tags=[SWAGGER_TAG_WORKSPACE_ENDPOINTS])
148
+    @hapic.handle_exception(EmptyLabelNotAllowed, HTTPStatus.BAD_REQUEST)
149
+    @require_profile_or_other_profile_with_workspace_role(
150
+        Group.TIM_ADMIN,
151
+        Group.TIM_MANAGER,
152
+        UserRoleInWorkspace.WORKSPACE_MANAGER,
153
+    )
154
+    @hapic.input_path(WorkspaceIdPathSchema())
155
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
156
+    def undelete_workspace(self, context, request: TracimRequest, hapic_data=None):  # nopep8
157
+        """
158
+        restore deleted workspace
159
+        """
160
+        app_config = request.registry.settings['CFG']
161
+        wapi = WorkspaceApi(
162
+            current_user=request.current_user,  # User
163
+            session=request.dbsession,
164
+            config=app_config,
165
+            show_deleted=True,
166
+        )
167
+        wapi.undelete(request.current_workspace, flush=True)
168
+        return
169
+
170
+    @hapic.with_api_doc(tags=[SWAGGER_TAG_WORKSPACE_ENDPOINTS])
121 171
     @require_workspace_role(UserRoleInWorkspace.READER)
122 172
     @hapic.input_path(WorkspaceIdPathSchema())
123 173
     @hapic.output_body(WorkspaceMemberSchema(many=True))
@@ -176,6 +226,28 @@ class WorkspaceController(Controller):
176 226
         return rapi.get_user_role_workspace_with_context(role)
177 227
 
178 228
     @hapic.with_api_doc(tags=[SWAGGER_TAG_WORKSPACE_ENDPOINTS])
229
+    @require_workspace_role(UserRoleInWorkspace.WORKSPACE_MANAGER)
230
+    @hapic.input_path(WorkspaceAndUserIdPathSchema())
231
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
232
+    def delete_workspaces_members_role(
233
+            self,
234
+            context,
235
+            request: TracimRequest,
236
+            hapic_data=None
237
+    ) -> None:
238
+        app_config = request.registry.settings['CFG']
239
+        rapi = RoleApi(
240
+            current_user=request.current_user,
241
+            session=request.dbsession,
242
+            config=app_config,
243
+        )
244
+        rapi.delete_one(
245
+            user_id=hapic_data.path.user_id,
246
+            workspace_id=hapic_data.path.workspace_id,
247
+        )
248
+        return
249
+
250
+    @hapic.with_api_doc(tags=[SWAGGER_TAG_WORKSPACE_ENDPOINTS])
179 251
     @hapic.handle_exception(UserCreationFailed, HTTPStatus.BAD_REQUEST)
180 252
     @require_workspace_role(UserRoleInWorkspace.WORKSPACE_MANAGER)
181 253
     @hapic.input_path(WorkspaceIdPathSchema())
@@ -215,7 +287,7 @@ class WorkspaceController(Controller):
215 287
                 # notification for creation
216 288
                 user = uapi.create_user(
217 289
                     email=hapic_data.body.user_email_or_public_name,
218
-                    password= password_generator(),
290
+                    password=password_generator(),
219 291
                     do_notify=True
220 292
                 )  # nopep8
221 293
                 newly_created = True
@@ -276,6 +348,7 @@ class WorkspaceController(Controller):
276 348
     @hapic.with_api_doc(tags=[SWAGGER_TAG_WORKSPACE_ENDPOINTS])
277 349
     @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR)
278 350
     @hapic.handle_exception(EmptyLabelNotAllowed, HTTPStatus.BAD_REQUEST)
351
+    @hapic.handle_exception(UnallowedSubContent, HTTPStatus.BAD_REQUEST)
279 352
     @hapic.input_path(WorkspaceIdPathSchema())
280 353
     @hapic.input_body(ContentCreationSchema())
281 354
     @hapic.output_body(ContentDigestSchema())
@@ -314,6 +387,65 @@ class WorkspaceController(Controller):
314 387
         return content
315 388
 
316 389
     @hapic.with_api_doc(tags=[SWAGGER_TAG_WORKSPACE_ENDPOINTS])
390
+    @require_workspace_role(UserRoleInWorkspace.READER)
391
+    @hapic.input_path(WorkspaceAndContentIdPathSchema())
392
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.FOUND)  # nopep8
393
+    def get_content_from_workspace(
394
+            self,
395
+            context,
396
+            request: TracimRequest,
397
+            hapic_data=None,
398
+    ) -> None:
399
+        """
400
+        redirect to correct content file endpoint
401
+        """
402
+        app_config = request.registry.settings['CFG']
403
+        content = request.current_content
404
+        content_type = CONTENT_TYPES.get_one_by_slug(content.type).slug
405
+        # TODO - G.M - 2018-08-03 - Jsonify redirect response ?
406
+        raise HTTPFound(
407
+            "{base_url}workspaces/{workspace_id}/{content_type}s/{content_id}".format(
408
+                base_url=BASE_API_V2,
409
+                workspace_id=content.workspace_id,
410
+                content_type=content_type,
411
+                content_id=content.content_id,
412
+            )
413
+        )
414
+
415
+    @hapic.with_api_doc(tags=[SWAGGER_TAG_WORKSPACE_ENDPOINTS])
416
+    @hapic.input_path(ContentIdPathSchema())
417
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.FOUND)  # nopep8
418
+    def get_content(
419
+            self,
420
+            context,
421
+            request: TracimRequest,
422
+            hapic_data=None,
423
+    ) -> None:
424
+        """
425
+        redirect to correct content file endpoint
426
+        """
427
+        app_config = request.registry.settings['CFG']
428
+        api = ContentApi(
429
+            current_user=request.current_user,
430
+            session=request.dbsession,
431
+            config=app_config,
432
+        )
433
+        content = api.get_one(
434
+            content_id=hapic_data.path['content_id'],
435
+            content_type=CONTENT_TYPES.Any_SLUG
436
+        )
437
+        content_type = CONTENT_TYPES.get_one_by_slug(content.type).slug
438
+        # TODO - G.M - 2018-08-03 - Jsonify redirect response ?
439
+        raise HTTPFound(
440
+            "{base_url}workspaces/{workspace_id}/{content_type}s/{content_id}".format(
441
+                base_url=BASE_API_V2,
442
+                workspace_id=content.workspace_id,
443
+                content_type=content_type,
444
+                content_id=content.content_id,
445
+            )
446
+        )
447
+
448
+    @hapic.with_api_doc(tags=[SWAGGER_TAG_WORKSPACE_ENDPOINTS])
317 449
     @hapic.handle_exception(WorkspacesDoNotMatch, HTTPStatus.BAD_REQUEST)
318 450
     @require_workspace_role(UserRoleInWorkspace.CONTENT_MANAGER)
319 451
     @require_candidate_workspace_role(UserRoleInWorkspace.CONTENT_MANAGER)
@@ -512,6 +644,11 @@ class WorkspaceController(Controller):
512 644
         # Create workspace
513 645
         configurator.add_route('create_workspace', '/workspaces', request_method='POST')  # nopep8
514 646
         configurator.add_view(self.create_workspace, route_name='create_workspace')  # nopep8
647
+        # Delete/Undelete workpace
648
+        configurator.add_route('delete_workspace', '/workspaces/{workspace_id}/delete', request_method='PUT')  # nopep8
649
+        configurator.add_view(self.delete_workspace, route_name='delete_workspace')  # nopep8
650
+        configurator.add_route('undelete_workspace', '/workspaces/{workspace_id}/undelete', request_method='PUT')  # nopep8
651
+        configurator.add_view(self.undelete_workspace, route_name='undelete_workspace')  # nopep8
515 652
         # Update Workspace
516 653
         configurator.add_route('update_workspace', '/workspaces/{workspace_id}', request_method='PUT')  # nopep8
517 654
         configurator.add_view(self.update_workspace, route_name='update_workspace')  # nopep8
@@ -524,12 +661,21 @@ class WorkspaceController(Controller):
524 661
         # Create Workspace Members roles
525 662
         configurator.add_route('create_workspace_member', '/workspaces/{workspace_id}/members', request_method='POST')  # nopep8
526 663
         configurator.add_view(self.create_workspaces_members_role, route_name='create_workspace_member')  # nopep8
664
+        # Delete Workspace Members roles
665
+        configurator.add_route('delete_workspace_member', '/workspaces/{workspace_id}/members/{user_id}', request_method='DELETE')  # nopep8
666
+        configurator.add_view(self.delete_workspaces_members_role, route_name='delete_workspace_member')  # nopep8
527 667
         # Workspace Content
528 668
         configurator.add_route('workspace_content', '/workspaces/{workspace_id}/contents', request_method='GET')  # nopep8
529 669
         configurator.add_view(self.workspace_content, route_name='workspace_content')  # nopep8
530 670
         # Create Generic Content
531 671
         configurator.add_route('create_generic_content', '/workspaces/{workspace_id}/contents', request_method='POST')  # nopep8
532 672
         configurator.add_view(self.create_generic_empty_content, route_name='create_generic_content')  # nopep8
673
+        # Get Content
674
+        configurator.add_route('get_content', '/contents/{content_id}', request_method='GET')  # nopep8
675
+        configurator.add_view(self.get_content, route_name='get_content')
676
+        # Get Content From workspace
677
+        configurator.add_route('get_content_from_workspace', '/workspaces/{workspace_id}/contents/{content_id}', request_method='GET')  # nopep8
678
+        configurator.add_view(self.get_content_from_workspace, route_name='get_content_from_workspace')  # nopep8
533 679
         # Move Content
534 680
         configurator.add_route('move_content', '/workspaces/{workspace_id}/contents/{content_id}/move', request_method='PUT')  # nopep8
535 681
         configurator.add_view(self.move_content, route_name='move_content')  # nopep8

+ 52 - 0
backend_lib.sh View File

@@ -0,0 +1,52 @@
1
+. bash_library.sh # source bash_library.sh
2
+
3
+function install_backend_system_dep {
4
+    log "install base debian-packaged-dep for backend..."
5
+    sudo apt update
6
+    sudo apt install -y python3 python3-venv python3-dev python3-pip
7
+    sudo apt install -y redis-server
8
+
9
+    log "install deps for dealing with most preview..."
10
+    sudo apt install -y zlib1g-dev libjpeg-dev
11
+    sudo apt install -y imagemagick libmagickwand-dev ghostscript
12
+    sudo apt install -y libreoffice # most office documents file and text format
13
+    sudo apt install -y inkscape # for .svg files.
14
+}
15
+
16
+function setup_pyenv {
17
+   log "setup python3 env.."
18
+   python3 -m venv env
19
+   source env/bin/activate
20
+}
21
+
22
+function install_backend_python_packages {
23
+    pip install --upgrade pip setuptools
24
+
25
+    log "install tracim-backend (sqlite_backend)..."
26
+    pip install -e ".[testing]"
27
+}
28
+
29
+function setup_config_file {
30
+    log "configure tracim with default conf..."
31
+    if [ ! -f development.ini ]; then
32
+       log "generate missing development.ini ..."
33
+       cp development.ini.sample development.ini
34
+    fi
35
+
36
+    if [ ! -f wsgidav.conf ]; then
37
+       log "generate missing wsgidav.conf ..."
38
+       cp wsgidav.conf.sample wsgidav.conf
39
+    fi
40
+}
41
+
42
+function setup_db {
43
+    result=$(alembic -c development.ini current)
44
+    if [ $? -eq 0 ] && [ ! "$result" == '' ]; then
45
+       log "check database migration..."
46
+       alembic -c development.ini upgrade head
47
+    else
48
+       log "database seems missing, init it..."
49
+       tracimcli db init
50
+       alembic -c development.ini stamp head
51
+    fi
52
+}

+ 34 - 2
frontend/src/action-creator.async.js View File

@@ -17,6 +17,8 @@ import {
17 17
   setFolderData,
18 18
   APP_LIST,
19 19
   CONTENT_TYPE_LIST,
20
+  WORKSPACE_CONTENT_ARCHIVED,
21
+  WORKSPACE_CONTENT_DELETED,
20 22
   WORKSPACE_RECENT_ACTIVITY,
21 23
   WORKSPACE_READ_STATUS
22 24
 } from './action-creator.sync.js'
@@ -240,9 +242,9 @@ export const getWorkspaceContentList = (user, idWorkspace, idParent) => dispatch
240 242
   })
241 243
 }
242 244
 
243
-export const getWorkspaceRecentActivityList = (user, idWorkspace) => dispatch => {
245
+export const getWorkspaceRecentActivityList = (user, idWorkspace, beforeId = null) => dispatch => {
244 246
   return fetchWrapper({
245
-    url: `${FETCH_CONFIG.apiUrl}/users/${user.user_id}/workspaces/${idWorkspace}/contents/recently_active?limit=10`,
247
+    url: `${FETCH_CONFIG.apiUrl}/users/${user.user_id}/workspaces/${idWorkspace}/contents/recently_active?limit=10${beforeId ? `&before_content_id=${beforeId}` : ''}`,
246 248
     param: {
247 249
       headers: {
248 250
         ...FETCH_CONFIG.headers,
@@ -332,3 +334,33 @@ export const getContentTypeList = user => dispatch => {
332 334
     dispatch
333 335
   })
334 336
 }
337
+
338
+export const putWorkspaceContentArchived = (user, idWorkspace, idContent) => dispatch => {
339
+  return fetchWrapper({
340
+    url: `${FETCH_CONFIG.apiUrl}/workspaces/${idWorkspace}/contents/${idContent}/archive`,
341
+    param: {
342
+      headers: {
343
+        ...FETCH_CONFIG.headers,
344
+        'Authorization': 'Basic ' + user.auth
345
+      },
346
+      method: 'PUT'
347
+    },
348
+    actionName: WORKSPACE_CONTENT_ARCHIVED,
349
+    dispatch
350
+  })
351
+}
352
+
353
+export const putWorkspaceContentDeleted = (user, idWorkspace, idContent) => dispatch => {
354
+  return fetchWrapper({
355
+    url: `${FETCH_CONFIG.apiUrl}/workspaces/${idWorkspace}/contents/${idContent}/delete`,
356
+    param: {
357
+      headers: {
358
+        ...FETCH_CONFIG.headers,
359
+        'Authorization': 'Basic ' + user.auth
360
+      },
361
+      method: 'PUT'
362
+    },
363
+    actionName: WORKSPACE_CONTENT_DELETED,
364
+    dispatch
365
+  })
366
+}

+ 8 - 3
frontend/src/action-creator.sync.js View File

@@ -2,6 +2,7 @@ export const SET = 'Set'
2 2
 export const UPDATE = 'Update'
3 3
 export const ADD = 'Add'
4 4
 export const REMOVE = 'Remove'
5
+export const APPEND = 'Append'
5 6
 
6 7
 export const TIMEZONE = 'Timezone'
7 8
 export const setTimezone = timezone => ({ type: `${SET}/${TIMEZONE}`, timezone })
@@ -11,7 +12,7 @@ export const newFlashMessage = (msgText = '', msgType = 'info', msgDelay = 5000)
11 12
   msgDelay !== 0 && window.setTimeout(() => dispatch(removeFlashMessage(msgText)), msgDelay)
12 13
   return dispatch(addFlashMessage({message: msgText, type: msgType}))
13 14
 }
14
-export const addFlashMessage = msg => ({ type: `${ADD}/${FLASH_MESSAGE}`, msg })
15
+const addFlashMessage = msg => ({ type: `${ADD}/${FLASH_MESSAGE}`, msg }) // only newFlashMsg should be used by component and app so dont export this
15 16
 export const removeFlashMessage = msg => ({ type: `${REMOVE}/${FLASH_MESSAGE}`, msg })
16 17
 
17 18
 export const USER = 'User'
@@ -37,6 +38,11 @@ export const WORKSPACE_CONTENT = `${WORKSPACE}/Content`
37 38
 export const setWorkspaceContentList = workspaceContentList => ({ type: `${SET}/${WORKSPACE_CONTENT}`, workspaceContentList })
38 39
 export const updateWorkspaceFilter = filterList => ({ type: `${UPDATE}/${WORKSPACE}/Filter`, filterList })
39 40
 
41
+export const WORKSPACE_CONTENT_ARCHIVED = `${WORKSPACE_CONTENT}/Archived`
42
+export const WORKSPACE_CONTENT_DELETED = `${WORKSPACE_CONTENT}/Deleted`
43
+export const setWorkspaceContentArchived = (idWorkspace, idContent) => ({ type: `${SET}/${WORKSPACE_CONTENT_ARCHIVED}`, idWorkspace, idContent })
44
+export const setWorkspaceContentDeleted = (idWorkspace, idContent) => ({ type: `${SET}/${WORKSPACE_CONTENT_DELETED}`, idWorkspace, idContent })
45
+
40 46
 export const WORKSPACE_LIST = `${WORKSPACE}/List`
41 47
 export const updateWorkspaceListData = workspaceList => ({ type: `${UPDATE}/${WORKSPACE_LIST}`, workspaceList })
42 48
 export const setWorkspaceListIsOpenInSidebar = (workspaceId, isOpenInSidebar) => ({ type: `${SET}/${WORKSPACE_LIST}/isOpenInSidebar`, workspaceId, isOpenInSidebar })
@@ -52,8 +58,7 @@ export const WORKSPACE_MEMBER_ADD = `${WORKSPACE_MEMBER}/${ADD}`
52 58
 export const WORKSPACE_RECENT_ACTIVITY = `${WORKSPACE}/RecentActivity/List`
53 59
 export const WORKSPACE_RECENT_ACTIVITY_LIST = `${WORKSPACE_RECENT_ACTIVITY}/List`
54 60
 export const setWorkspaceRecentActivityList = workspaceRecentActivityList => ({ type: `${SET}/${WORKSPACE_RECENT_ACTIVITY_LIST}`, workspaceRecentActivityList })
55
-export const WORKSPACE_RECENT_ACTIVITY_FOR_USER_LIST = `${WORKSPACE_RECENT_ACTIVITY}/ForUser/List`
56
-export const setWorkspaceRecentActivityForUserList = workspaceRecentActivityForUserList => ({ type: `${SET}/${WORKSPACE_RECENT_ACTIVITY_FOR_USER_LIST}`, workspaceRecentActivityForUserList })
61
+export const appendWorkspaceRecentActivityList = workspaceRecentActivityList => ({ type: `${APPEND}/${WORKSPACE_RECENT_ACTIVITY_LIST}`, workspaceRecentActivityList })
57 62
 
58 63
 export const WORKSPACE_READ_STATUS = `${WORKSPACE}/ReadStatus`
59 64
 export const WORKSPACE_READ_STATUS_LIST = `${WORKSPACE_READ_STATUS}/List`

+ 3 - 3
frontend/src/component/Account/Password.jsx View File

@@ -17,16 +17,16 @@ export const Password = props => {
17 17
           {props.t('Password')}
18 18
         </div>
19 19
         <input
20
-          className='personaldata__form__txtinput form-control'
20
+          className='personaldata__form__txtinput primaryColorBorderLighten form-control'
21 21
           type='password'
22 22
           placeholder={props.t('Old password')}
23 23
         />
24 24
         <input
25
-          className='personaldata__form__txtinput form-control mt-4'
25
+          className='personaldata__form__txtinput primaryColorBorderLighten form-control mt-4'
26 26
           type='password'
27 27
           placeholder={props.t('New password')}
28 28
         />
29
-        <button type='submit' className='personaldata__form__button btn btn-outline-primary mt-4'>
29
+        <button type='submit' className='personaldata__form__button primaryColorBorderLighten btn btn-outline-primary mt-4'>
30 30
           {props.t('Send')}
31 31
         </button>
32 32
       </form>

+ 3 - 3
frontend/src/component/Account/PersonalData.jsx View File

@@ -19,7 +19,7 @@ export const PersonalData = props => {
19 19
         </div>
20 20
         <div className='d-flex align-items-center justify-content-between flex-wrap mb-4'>
21 21
           <input
22
-            className='personaldata__form__txtinput form-control mt-3 mt-sm-0'
22
+            className='personaldata__form__txtinput primaryColorBorderLighten form-control mt-3 mt-sm-0'
23 23
             type='text'
24 24
             placeholder={props.t('Change your name')}
25 25
           />
@@ -29,12 +29,12 @@ export const PersonalData = props => {
29 29
         </div>
30 30
         <div className='d-flex align-items-center justify-content-between flex-wrap mb-4'>
31 31
           <input
32
-            className='personaldata__form__txtinput form-control mt-3 mt-sm-0'
32
+            className='personaldata__form__txtinput primaryColorBorderLighten form-control mt-3 mt-sm-0'
33 33
             type='email'
34 34
             placeholder={props.t('Change your email')}
35 35
           />
36 36
         </div>
37
-        <button type='submit' className='personaldata__form__button btn btn-outline-primary'>
37
+        <button type='submit' className='personaldata__form__button primaryColorBorderLighten btn btn-outline-primary'>
38 38
           {props.t('Send')}
39 39
         </button>
40 40
       </form>

+ 2 - 2
frontend/src/component/Account/UserInfo.jsx View File

@@ -10,7 +10,7 @@ export const UserInfo = props => {
10 10
         <div className='account__userinformation__name mb-3'>
11 11
           {`${props.user.firstname} ${props.user.lastname}`}
12 12
         </div>
13
-        <a href={`mailto:${props.user.email}`} className='account__userinformation__email d-block mb-3'>
13
+        <a href={`mailto:${props.user.email}`} className='account__userinformation__email d-block primaryColorFontLighten mb-3'>
14 14
           {props.user.email}
15 15
         </a>
16 16
         <div className='account__userinformation__role mb-3'>
@@ -19,7 +19,7 @@ export const UserInfo = props => {
19 19
         { /* <div className='account__userinformation__job mb-3'>
20 20
           {props.user.job}
21 21
         </div>
22
-        <a href='http://www.algoo.fr' className='account__userinformation__company'>
22
+        <a href='http://www.algoo.fr' className='account__userinformation__company primaryColorFontLighten'>
23 23
           {props.user.company}
24 24
         </a> */ }
25 25
       </div>

+ 3 - 1
frontend/src/component/Dashboard/ContentTypeBtn.jsx View File

@@ -15,6 +15,7 @@ export const ContentTypeBtn = props =>
15 15
         backgroundColor: color(props.hexcolor).darken(0.15).hexString()
16 16
       }
17 17
     }}
18
+    onClick={props.onClickBtn}
18 19
   >
19 20
     <div className={classnames(`${props.customClass}__text`)}>
20 21
       <div className={classnames(`${props.customClass}__text__icon`)}>
@@ -33,7 +34,8 @@ ContentTypeBtn.propTypes = {
33 34
   label: PropTypes.string.isRequired,
34 35
   faIcon: PropTypes.string.isRequired,
35 36
   creationLabel: PropTypes.string.isRequired,
36
-  customClass: PropTypes.string
37
+  customClass: PropTypes.string,
38
+  onClickBtn: PropTypes.func
37 39
 }
38 40
 
39 41
 ContentTypeBtn.defaultProps = {

+ 1 - 5
frontend/src/component/Dashboard/ContentTypeBtn.styl View File

@@ -4,7 +4,7 @@
4 4
   display flex
5 5
   flex-direction column
6 6
   justify-content center
7
-  margin 15px
7
+  margin 15px 15px 0 0
8 8
   border-radius 10px
9 9
   padding 15px
10 10
   width 230px
@@ -12,7 +12,3 @@
12 12
   box-shadow shadow-all
13 13
   text-align center
14 14
   cursor pointer
15
-  &:nth-child(1)
16
-    margin-left 0
17
-  &:nth-last-child
18
-    margin-right 0

+ 4 - 3
frontend/src/component/Dashboard/MemberList.jsx View File

@@ -1,6 +1,7 @@
1 1
 import React from 'react'
2 2
 import PropTypes from 'prop-types'
3 3
 // import { Checkbox } from 'tracim_frontend_lib'
4
+import { generateAvatarFromPublicName } from 'tracim_frontend_lib'
4 5
 
5 6
 require('./MemberList.styl')
6 7
 
@@ -45,7 +46,7 @@ export class MemberList extends React.Component {
45 46
                   {props.memberList.map(m =>
46 47
                     <li className='memberlist__list__item' key={m.id}>
47 48
                       <div className='memberlist__list__item__avatar'>
48
-                        {m.avatarUrl ? <img src={m.avatarUrl} /> : <img src='NYI' />}
49
+                        <img src={m.avatarUrl} />
49 50
                       </div>
50 51
 
51 52
                       <div className='memberlist__list__item__info mr-auto'>
@@ -110,8 +111,8 @@ export class MemberList extends React.Component {
110 111
                             onClick={() => props.onClickKnownMember(u)}
111 112
                             key={u.user_id}
112 113
                           >
113
-                            <div className='autocomplete__item__avatar primaryColorBorder'>
114
-                              <img src={u.avatar_url} />
114
+                            <div className='autocomplete__item__avatar'>
115
+                              <img src={u.avatar_url ? u.avatar_url : generateAvatarFromPublicName(u.public_name)} />
115 116
                             </div>
116 117
 
117 118
                             <div className='autocomplete__item__name'>

+ 18 - 12
frontend/src/component/Dashboard/MemberList.styl View File

@@ -21,14 +21,6 @@
21 21
       display flex
22 22
       border-bottom 1px solid grey
23 23
       padding 10px 15px
24
-      &:hover
25
-        background-color fourthColor
26
-      &:nth-last-child(1)
27
-        border-bottom 0
28
-      &:nth-child(even)
29
-        background-color grey-hover
30
-        &:hover
31
-          background-color fourthColor
32 24
       &__avatar
33 25
         margin-right 20px
34 26
         & > img
@@ -105,8 +97,6 @@
105 97
               width 45px
106 98
               height 45px
107 99
               border-radius 50%
108
-              border-width 1px
109
-              border-style solid
110 100
             &__name
111 101
               margin-left 15px
112 102
       .name__input
@@ -146,14 +136,30 @@
146 136
         padding 8px 30px
147 137
         cursor pointer
148 138
 
149
-@media (min-width min-sm) and (max-width: max-lg)
139
+/***** MEDIAQUERIES *****/
140
+
141
+/*** MEDIA 992px and 1199px ***/
142
+
143
+@media (min-width: min-lg) and (max-width: max-lg)
144
+
150 145
   .memberlist
151 146
     width 50%
152 147
 
148
+/*** MEDIA 768px and 991px ***/
149
+
150
+@media (min-width: min-md) and (max-width: max-md)
151
+
152
+  .memberlist
153
+    width 50%
154
+
155
+/*** MEDIA 576px and 767px ***/
156
+
153 157
 @media (min-width: min-sm) and (max-width: max-sm)
154 158
   .memberlist
155 159
     margin 50px 0
156
-    width 90%
160
+    width 100%
161
+
162
+/*** MEDIA 575px ***/
157 163
 
158 164
 @media (max-width: max-xs)
159 165
   .memberlist

+ 2 - 2
frontend/src/component/Dashboard/MoreInfo.jsx View File

@@ -6,7 +6,7 @@ export const MoreInfo = props =>
6 6
   <div className='moreinfo'>
7 7
     <div className='moreinfo__webdav genericBtnInfoDashboard'>
8 8
       <div
9
-        className='moreinfo__webdav__btn genericBtnInfoDashboard__btn'
9
+        className='moreinfo__webdav__btn genericBtnInfoDashboard__btn primaryColorBorderLighten primaryColorFontLighten'
10 10
         onClick={props.onClickToggleWebdav}
11 11
       >
12 12
         <div className='moreinfo__webdav__btn__icon genericBtnInfoDashboard__btn__icon'>
@@ -41,7 +41,7 @@ export const MoreInfo = props =>
41 41
             <i className='fa fa-calendar' />
42 42
           </div>
43 43
 
44
-          <div className='moreinfo__calendar__btn__text genericBtnInfoDashboard__btn__text'>
44
+          <div className='moreinfo__calendar__btn__text genericBtnInfoDashboard__btn__text d-flex align-self-center'>
45 45
             {props.t('Workspace Calendar')}
46 46
           </div>
47 47
         </div>

+ 1 - 0
frontend/src/component/Dashboard/MoreInfo.styl View File

@@ -9,6 +9,7 @@
9 9
     &__information
10 10
       width 300px
11 11
   &__calendar
12
+    display none
12 13
     margin-bottom 100px
13 14
     &__wrapperBtn
14 15
       margin-right 290px

+ 9 - 4
frontend/src/component/Dashboard/RecentActivity.jsx View File

@@ -20,11 +20,11 @@ export const RecentActivity = props =>
20 20
     </div>
21 21
 
22 22
     <div className='activity__wrapper'>
23
-      {props.recentActivityFilteredForUser.map(content => {
23
+      {props.recentActivityList.map(content => {
24 24
         const contentType = props.contentTypeList.find(ct => ct.slug === content.type)
25 25
         return (
26 26
           <div
27
-            className='activity__workspace'
27
+            className={classnames('activity__workspace', {'read': props.readByUserList.includes(content.id)})}
28 28
             onClick={() => props.onClickRecentContent(content.id, content.type)}
29 29
             key={content.id}
30 30
           >
@@ -53,7 +53,12 @@ export default RecentActivity
53 53
 
54 54
 RecentActivity.propTypes = {
55 55
   t: PropTypes.func.isRequired,
56
-  recentActivityFilteredForUser: PropTypes.array.isRequired,
56
+  recentActivityList: PropTypes.array.isRequired,
57 57
   contentTypeList: PropTypes.array.isRequired,
58
-  onClickSeeMore: PropTypes.func.isRequired
58
+  onClickSeeMore: PropTypes.func.isRequired,
59
+  readByUserList: PropTypes.array
60
+}
61
+
62
+RecentActivity.defaultProps = {
63
+  readByUserList: []
59 64
 }

+ 22 - 6
frontend/src/component/Dashboard/RecentActivity.styl View File

@@ -23,6 +23,9 @@
23 23
     border-bottom 1px solid grey
24 24
     padding 15px
25 25
     cursor pointer
26
+    font-weight bold
27
+    &.read
28
+      font-weight normal
26 29
     &:hover
27 30
       background-color fourthColor
28 31
     &:nth-child(even)
@@ -34,31 +37,44 @@
34 37
       font-size 25px
35 38
     &__name
36 39
       font-size 18px
37
-      font-weight 500
38
-      span
39
-        font-weight 400
40 40
   &__more
41 41
     &__btn
42 42
       margin 15px
43 43
       padding 10px 25px
44 44
       cursor pointer
45 45
 
46
-@media (min-width min-sm) and (max-width: max-lg)
46
+
47
+/**** MEDIAQUERIES ****/
48
+
49
+/**** MEDIA 992px and 1199px ****/
50
+
51
+@media (min-width min-lg) and (max-width: max-lg)
47 52
   .activity
53
+    margin-right 0
48 54
     width 100%
49 55
 
56
+/**** MEDIA 768px and 991px ****/
57
+
50 58
 @media (min-width: min-md) and (max-width: max-md)
51 59
   .activity
52
-    margin 25px 15px 25px 0
60
+    margin 25px 0 25px 0
61
+    width 100%
62
+
63
+/**** MEDIA 576px and 767px ****/
53 64
 
54 65
 @media (min-width: min-sm) and (max-width: max-sm)
55 66
   .activity
56
-    margin 25px 15px 25px 0
67
+    margin 25px 0 25px 0
68
+    width 100%
69
+
70
+/**** MEDIA 575px ****/
57 71
 
58 72
 @media (max-width: max-xs)
59 73
   .activity
60 74
     margin 25px 0
61 75
     width 100%
76
+    &__wrapper
77
+      margin-top 45px
62 78
     &__header
63 79
       display block
64 80
       height auto

+ 2 - 2
frontend/src/component/Dashboard/UserStatus.jsx View File

@@ -40,7 +40,7 @@ export const UserStatus = props =>
40 40
         ? (
41 41
           <div className='userstatus__notification__subscribe dropdown'>
42 42
             <button
43
-              className='userstatus__notification__subscribe__btn btn btn-outline-primary dropdown-toggle'
43
+              className='userstatus__notification__subscribe__btn btn btn-outline-primary dropdown-toggle primaryColorBorderLighten'
44 44
               type='button'
45 45
               id='dropdownMenuButton'
46 46
               data-toggle='dropdown'
@@ -62,7 +62,7 @@ export const UserStatus = props =>
62 62
         )
63 63
         : (
64 64
           <div
65
-            className='userstatus__notification__btn btn btn-outline-primary'
65
+            className='userstatus__notification__btn btn btn-outline-primary primaryColorBorderLighten'
66 66
             onClick={props.onClickToggleNotifBtn}
67 67
           >
68 68
             {props.t('Change your status')}

+ 41 - 2
frontend/src/component/Dashboard/UserStatus.styl View File

@@ -16,15 +16,54 @@
16 16
     font-size 18px
17 17
     &__btn
18 18
       margin 20px 0
19
-      border 1px solid thirdColor
19
+      border-width 1px
20
+      border-style solid
20 21
       padding 10px 15px
21 22
       cursor pointer
22 23
     &__subscribe
23 24
       &__btn
24 25
         margin 20px 0
25
-        border 1px solid thirdColor
26
+        border-width 1px
27
+        border-style solid
26 28
         padding 10px 15px
27 29
       &__submenu
28 30
         padding 0
29 31
         &__item
30 32
           padding 10px
33
+
34
+/**** MEDIAQUERIES ****/
35
+
36
+/**** MEDIA 992px & 1199px ****/
37
+
38
+@media (min-width: min-lg) and (max-width: max-lg)
39
+
40
+  .userstatus
41
+    width 100%
42
+    &__role
43
+      display flex
44
+
45
+/**** MEDIA 768px & 991px ****/
46
+
47
+@media (min-width: min-md) and (max-width: max-md)
48
+
49
+  .userstatus
50
+    width 100%
51
+    &__role
52
+      display flex
53
+
54
+/**** MEDIA 576px & 767px ****/
55
+
56
+@media (min-width: min-sm) and (max-width: max-sm)
57
+
58
+  .userstatus
59
+    width 100%
60
+    &__role
61
+      display flex
62
+
63
+/**** MEDIA 575px ****/
64
+
65
+@media (max-width: max-xs)
66
+
67
+  .userstatus
68
+    width 100%
69
+

+ 29 - 0
frontend/src/component/Header/MenuActionListItem/AdminLink.jsx View File

@@ -0,0 +1,29 @@
1
+import React from 'react'
2
+import { Link } from 'react-router-dom'
3
+import { PAGE } from '../../../helper.js'
4
+
5
+const AdminLink = props => {
6
+  return (
7
+    <li className='header__menu__rightside__adminlink'>
8
+      <div className='adminlink dropdown'>
9
+        <button className='adminlink__btn btn dropdown-toggle' type='button' data-toggle='dropdown'>
10
+          Administration
11
+        </button>
12
+
13
+        <div className='adminlink__setting dropdown-menu' aria-labelledby='dropdownMenuButton'>
14
+          <Link className='setting__link dropdown-item' to={PAGE.ADMIN.WORKSPACE}>
15
+            <i className='fa fa-fw fa-space-shuttle mr-2' />
16
+            {props.t('Admin workspace')}
17
+          </Link>
18
+
19
+          <Link className='setting__link dropdown-item' to={PAGE.ADMIN.USER}>
20
+            <i className='fa fa-fw fa-users mr-2' />
21
+            {props.t('Admin user')}
22
+          </Link>
23
+        </div>
24
+      </div>
25
+    </li>
26
+  )
27
+}
28
+
29
+export default AdminLink

+ 26 - 23
frontend/src/component/Header/MenuActionListItem/MenuProfil.jsx View File

@@ -5,31 +5,34 @@ import { PAGE } from '../../../helper.js'
5 5
 import { translate } from 'react-i18next'
6 6
 
7 7
 const MenuProfil = props => {
8
-  return props.user.logged
9
-    ? (
10
-      <li className='header__menu__rightside__itemprofil'>
11
-        <div className='profilgroup dropdown'>
12
-          <button className='profilgroup__name btn btn-outline-primary dropdown-toggle' type='button' id='dropdownMenuButton' data-toggle='dropdown' aria-haspopup='true' aria-expanded='false'>
13
-            <img className='profilgroup__name__imgprofil' src={props.user.avatar_url} />
14
-            <div className='profilgroup__name__text'>
15
-              {props.user.name}
16
-            </div>
17
-          </button>
18
-          <div className='profilgroup__setting dropdown-menu' aria-labelledby='dropdownMenuButton'>
19
-            <Link className='setting__link dropdown-item' to={PAGE.ACCOUNT}>
20
-              <i className='fa fa-fw fa-user-o mr-2' />
21
-              {props.t('My Account')}
22
-            </Link>
23
-            {/* <div className='setting__link dropdown-item'>Mot de passe</div> */}
24
-            <div className='setting__link dropdown-item' onClick={props.onClickLogout}>
25
-              <i className='fa fa-fw fa-sign-out mr-2' />
26
-              {props.t('Logout')}
27
-            </div>
8
+  if (!props.user.logged) return null
9
+
10
+  return (
11
+    <li className='header__menu__rightside__itemprofil'>
12
+      <div className='profilgroup dropdown'>
13
+        <button className='profilgroup__name btn btn-outline-primary dropdown-toggle' type='button' id='dropdownMenuButton' data-toggle='dropdown' aria-haspopup='true' aria-expanded='false'>
14
+          <img className='profilgroup__name__imgprofil' src={props.user.avatar_url} />
15
+
16
+          <div className='profilgroup__name__text'>
17
+            {props.user.public_name}
18
+          </div>
19
+        </button>
20
+
21
+        <div className='profilgroup__setting dropdown-menu' aria-labelledby='dropdownMenuButton'>
22
+          <Link className='setting__link dropdown-item' to={PAGE.ACCOUNT}>
23
+            <i className='fa fa-fw fa-user-o mr-2' />
24
+            {props.t('My Account')}
25
+          </Link>
26
+
27
+          {/* <div className='setting__link dropdown-item'>Mot de passe</div> */}
28
+          <div className='setting__link dropdown-item' onClick={props.onClickLogout}>
29
+            <i className='fa fa-fw fa-sign-out mr-2' />
30
+            {props.t('Logout')}
28 31
           </div>
29 32
         </div>
30
-      </li>
31
-    )
32
-    : ''
33
+      </div>
34
+    </li>
35
+  )
33 36
 }
34 37
 export default translate()(MenuProfil)
35 38
 

+ 1 - 1
frontend/src/component/Header/MenuActionListItem/Search.jsx View File

@@ -14,7 +14,7 @@ const Search = props => {
14 14
           onChange={props.onChangeInput}
15 15
         />
16 16
         <button
17
-          className='search__addonsearch input-group-addon'
17
+          className='search__addonsearch input-group-addon primaryColorBgLightenHover primaryColorFontHover'
18 18
           id='headerInputSearch'
19 19
           onClick={props.onClickSubmit}
20 20
         >

+ 8 - 8
frontend/src/component/Sidebar/WorkspaceListItem.jsx View File

@@ -7,27 +7,27 @@ import { Link } from 'react-router-dom'
7 7
 
8 8
 const WorkspaceListItem = props => {
9 9
   return (
10
-    <li className='sidebar__navigation__workspace__item'>
10
+    <li className='sidebar__content__navigation__workspace__item'>
11 11
       <div
12
-        className='sidebar__navigation__workspace__item__wrapper primaryColorBg primaryColorBgDarkenHover primaryColorBorder'
12
+        className='sidebar__content__navigation__workspace__item__wrapper primaryColorBg primaryColorBgDarkenHover primaryColorBorder'
13 13
         onClick={props.onClickTitle}
14 14
       >
15
-        <div className='sidebar__navigation__workspace__item__number'>
15
+        <div className='sidebar__content__navigation__workspace__item__number'>
16 16
           {props.label.substring(0, 2).toUpperCase()}
17 17
         </div>
18 18
 
19
-        <div className='sidebar__navigation__workspace__item__name' title={props.label}>
19
+        <div className='sidebar__content__navigation__workspace__item__name' title={props.label}>
20 20
           {props.label}
21 21
         </div>
22 22
 
23
-        <div className='sidebar__navigation__workspace__item__icon'>
23
+        <div className='sidebar__content__navigation__workspace__item__icon'>
24 24
           <i className={classnames(props.isOpenInSidebar ? 'fa fa-chevron-up' : 'fa fa-chevron-down')} />
25 25
         </div>
26 26
       </div>
27 27
 
28 28
       <AnimateHeight duration={500} height={props.isOpenInSidebar ? 'auto' : 0}>
29 29
         <ul
30
-          className='sidebar__navigation__workspace__item__submenu'
30
+          className='sidebar__content__navigation__workspace__item__submenu'
31 31
           id={`sidebarSubMenu_${props.number}`}
32 32
         >
33 33
           { props.allowedApp.map(aa =>
@@ -37,14 +37,14 @@ const WorkspaceListItem = props => {
37 37
             >
38 38
               <Link to={aa.route}>
39 39
                 <div className={classnames(
40
-                  'sidebar__navigation__workspace__item__submenu__dropdown primaryColorBgLighten primaryColorBgHover primaryColorBorderDarken',
40
+                  'sidebar__content__navigation__workspace__item__submenu__dropdown primaryColorBgLighten primaryColorBgHover primaryColorBorderDarken',
41 41
                   {'activeFilter': props.activeFilterList.includes(aa.slug)}
42 42
                 )}>
43 43
                   <div className='dropdown__icon'>
44 44
                     <i className={classnames(`fa fa-${aa.faIcon}`)} style={{backgroudColor: aa.hexcolor}} />
45 45
                   </div>
46 46
 
47
-                  <div className='sidebar__navigation__workspace__item__submenu__dropdown__showdropdown'>
47
+                  <div className='sidebar__content__navigation__workspace__item__submenu__dropdown__showdropdown'>
48 48
                     <div className='dropdown__title' id='navbarDropdown'>
49 49
                       <div className='dropdown__title__text'>
50 50
                         {aa.label/* [props.lang.id] */}

+ 1 - 1
frontend/src/component/common/Input/SubDropdownCreateButton.jsx View File

@@ -15,7 +15,7 @@ const SubDropdownCreateButton = props => {
15 15
                 style={{color: app.hexcolor}}
16 16
               />
17 17
             </div>
18
-            <div className='subdropdown__link__folder__text'>
18
+            <div className={`subdropdown__link__${app.slug}__text`}>
19 19
               {app.creationLabel}
20 20
             </div>
21 21
           </div>

+ 1 - 1
frontend/src/container/Account.jsx View File

@@ -101,7 +101,7 @@ class Account extends React.Component {
101 101
     })()
102 102
 
103 103
     return (
104
-      <div className='Account'>
104
+      <div className='account'>
105 105
         <PageWrapper customClass='account'>
106 106
           <PageTitle
107 107
             parentClass={'account'}

+ 128 - 71
frontend/src/container/AdminWorkspacePage.jsx View File

@@ -1,6 +1,4 @@
1 1
 import React from 'react'
2
-import PropTypes from 'prop-types'
3
-import Sidebar from './Sidebar.jsx'
4 2
 import {
5 3
   Delimiter,
6 4
   PageWrapper,
@@ -12,127 +10,186 @@ import { translate } from 'react-i18next'
12 10
 class AdminWorkspacePage extends React.Component {
13 11
   render () {
14 12
     return (
15
-      <div className='sidebarpagecontainer'>
16
-        <Sidebar />
13
+      <PageWrapper customClass='adminWorkspacePage'>
14
+        <PageTitle
15
+          parentClass={'adminWorkspacePage'}
16
+          title={'Workspace management'}
17
+        />
17 18
 
18
-        <PageWrapper customClass='adminWorkspacePage'>
19
-          <PageTitle
20
-            parentClass={'adminWorkspacePage'}
21
-            title={'Workspace management'}
22
-          />
19
+        <PageContent parentClass='adminWorkspacePage'>
23 20
 
24
-          <PageContent parentClass='adminWorkspacePage'>
21
+          <div className='adminWorkspacePage__description'>
22
+            This page informs all workspaces of the instances
23
+          </div>
25 24
 
26
-            <div className='adminWorkspacePage__description'>
27
-              This page informs all workspaces of the instances
28
-            </div>
25
+          { /*
26
+            Alexi Cauvin 08/08/2018 => desactivate create workspace button due to redundancy
29 27
 
30 28
             <div className='adminWorkspacePage__createworkspace'>
31 29
               <button className='adminWorkspacePage__createworkspace__btncreate btn btn-primary primaryColorBg primaryColorBorder primaryColorBorderDarkenHover'>
32 30
                 {this.props.t('Create a workspace')}
33 31
               </button>
34 32
             </div>
33
+          */ }
35 34
 
36
-            <Delimiter customClass={'adminWorkspacePage__delimiter'} />
35
+          <Delimiter customClass={'adminWorkspacePage__delimiter'} />
37 36
 
38
-            <div className='adminWorkspacePage__workspaceTable'>
37
+          <div className='adminWorkspacePage__workspaceTable'>
39 38
 
40
-              <table class='table'>
41
-                <thead>
42
-                  <tr>
43
-                    <th scope='col'># ID</th>
44
-                    <th scope='col'>Workspace</th>
45
-                    <th scope='col'>Description</th>
46
-                    <th scope='col'>Users</th>
39
+            <table className='table'>
40
+              <thead>
41
+                <tr>
42
+                  <th scope='col'>ID</th>
43
+                  <th scope='col'>Workspace</th>
44
+                  <th scope='col'>Description</th>
45
+                  <th scope='col'>Member's number</th>
46
+                  { /*
47 47
                     <th scope='col'>Calendar</th>
48
-                  </tr>
49
-                </thead>
50
-                <tbody>
51
-                  <tr>
52
-                    <th>#1</th>
53
-                    <td>Design v_2</td>
54
-                    <td>Workspace about tracim v2 design</td>
55
-                    <td>8 users</td>
48
+                  */ }
49
+                  <th scope='col'>Delete workspace</th>
50
+                </tr>
51
+              </thead>
52
+              <tbody>
53
+                <tr>
54
+                  <th>1</th>
55
+                  <td>Design v_2</td>
56
+                  <td>Workspace about tracim v2 design</td>
57
+                  { /*
56 58
                     <td className='d-flex align-items-center flex-wrap'>
57 59
                       <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
58 60
                         <i className='fa fa-fw fa-check-square-o' />
59 61
                       </div>
60 62
                       Enable
61 63
                     </td>
62
-                  </tr>
63
-                  <tr>
64
-                    <th>#2</th>
65
-                    <td>New features</td>
66
-                    <td>Add a new features : Annotated files</td>
67
-                    <td>5 users</td>
64
+                  */ }
65
+                  <td>8</td>
66
+                  <td>
67
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
68
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
69
+                        <i className='fa fa-fw fa-trash-o' />
70
+                      </div>
71
+                      Delete
72
+                    </div>
73
+                  </td>
74
+                </tr>
75
+                <tr>
76
+                  <th>2</th>
77
+                  <td>New features</td>
78
+                  <td>Add a new features : Annotated files</td>
79
+                  { /*
68 80
                     <td className='d-flex align-items-center flex-wrap'>
69 81
                       <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
70 82
                         <i className='fa fa-fw fa-square-o' />
71 83
                       </div>
72 84
                       Disable
73 85
                     </td>
74
-                  </tr>
75
-                  <tr>
76
-                    <th>#3</th>
77
-                    <td>Fix Backend</td>
78
-                    <td>workspace referring to multiple issues on the backend </td>
79
-                    <td>3 users</td>
86
+                  */ }
87
+                  <td>5</td>
88
+                  <td>
89
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
90
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
91
+                        <i className='fa fa-fw fa-trash-o' />
92
+                      </div>
93
+                      Delete
94
+                    </div>
95
+                  </td>
96
+                </tr>
97
+                <tr>
98
+                  <th>3</th>
99
+                  <td>Fix Backend</td>
100
+                  <td>workspace referring to multiple issues on the backend </td>
101
+                  { /*
80 102
                     <td className='d-flex align-items-center flex-wrap'>
81 103
                       <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
82 104
                         <i className='fa fa-fw fa-check-square-o' />
83 105
                       </div>
84 106
                       Enable
85 107
                     </td>
86
-                  </tr>
87
-                  <tr>
88
-                    <th>#4</th>
89
-                    <td>Design v_2</td>
90
-                    <td>Workspace about tracim v2 design</td>
91
-                    <td>8 users</td>
108
+                  */ }
109
+                  <td>3</td>
110
+                  <td>
111
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
112
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
113
+                        <i className='fa fa-fw fa-trash-o' />
114
+                      </div>
115
+                      Delete
116
+                    </div>
117
+                  </td>
118
+                </tr>
119
+                <tr>
120
+                  <th>4</th>
121
+                  <td>Design v_2</td>
122
+                  <td>Workspace about tracim v2 design</td>
123
+                  { /*
92 124
                     <td className='d-flex align-items-center flex-wrap'>
93 125
                       <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
94 126
                         <i className='fa fa-fw fa-square-o' />
95 127
                       </div>
96 128
                       Disable
97 129
                     </td>
98
-                  </tr>
99
-                  <tr>
100
-                    <th>#5</th>
101
-                    <td>New features</td>
102
-                    <td>Add a new features : Annotated files</td>
103
-                    <td>5 users</td>
130
+                  */ }
131
+                  <td>8</td>
132
+                  <td>
133
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
134
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
135
+                        <i className='fa fa-fw fa-trash-o' />
136
+                      </div>
137
+                      Delete
138
+                    </div>
139
+                  </td>
140
+                </tr>
141
+                <tr>
142
+                  <th>5</th>
143
+                  <td>New features</td>
144
+                  <td>Add a new features : Annotated files</td>
145
+                  { /*
104 146
                     <td className='d-flex align-items-center flex-wrap'>
105 147
                       <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
106 148
                         <i className='fa fa-fw fa-square-o' />
107 149
                       </div>
108 150
                       Disable
109 151
                     </td>
110
-                  </tr>
111
-                  <tr>
112
-                    <th>#6</th>
113
-                    <td>Fix Backend</td>
114
-                    <td>workspace referring to multiple issues on the backend </td>
115
-                    <td>3 users</td>
152
+                  */ }
153
+                  <td>5</td>
154
+                  <td>
155
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
156
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
157
+                        <i className='fa fa-fw fa-trash-o' />
158
+                      </div>
159
+                      Delete
160
+                    </div>
161
+                  </td>
162
+                </tr>
163
+                <tr>
164
+                  <th>6</th>
165
+                  <td>Fix Backend</td>
166
+                  <td>workspace referring to multiple issues on the backend </td>
167
+                  { /*
116 168
                     <td className='d-flex align-items-center flex-wrap'>
117 169
                       <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
118 170
                         <i className='fa fa-fw fa-check-square-o' />
119 171
                       </div>
120 172
                       Enable
121 173
                     </td>
122
-                  </tr>
123
-                </tbody>
124
-              </table>
125
-            </div>
174
+                  */ }
175
+                  <td>3</td>
176
+                  <td>
177
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
178
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
179
+                        <i className='fa fa-fw fa-trash-o' />
180
+                      </div>
181
+                      Delete
182
+                    </div>
183
+                  </td>
184
+                </tr>
185
+              </tbody>
186
+            </table>
187
+          </div>
126 188
 
127
-          </PageContent>
128
-        </PageWrapper>
129
-      </div>
189
+        </PageContent>
190
+      </PageWrapper>
130 191
     )
131 192
   }
132 193
 }
133 194
 
134
-AdminWorkspacePage.propTypes = {
135
-  availableApp: PropTypes.array.isRequired
136
-}
137
-
138 195
 export default translate()(AdminWorkspacePage)

frontend/src/container/AppFullscreenManager.jsx → frontend/src/container/AppFullscreenRouter.jsx View File

@@ -1,11 +1,11 @@
1 1
 import React from 'react'
2 2
 import { connect } from 'react-redux'
3 3
 import { withRouter } from 'react-router'
4
-import { Route } from 'react-router-dom'
5
-import { PAGE } from '../helper.js'
4
+import { Route, Redirect } from 'react-router-dom'
5
+import { PAGE, PROFILE } from '../helper.js'
6 6
 import appFactory from '../appFactory.js'
7 7
 
8
-class AppFullscreenManager extends React.Component {
8
+class AppFullscreenRouter extends React.Component {
9 9
   constructor (props) {
10 10
     super(props)
11 11
     this.state = {
@@ -23,13 +23,17 @@ class AppFullscreenManager extends React.Component {
23 23
         <div id='appFullscreenContainer' />
24 24
 
25 25
         {this.state.isMounted && (// we must wait for the component to be fully mounted to be sure the div#appFullscreenContainer exists in DOM
26
-          <div className='emptyDiForRoute'>
26
+          <div className='emptyDivForRoute'>
27 27
             <Route path={PAGE.ADMIN.WORKSPACE} render={() => {
28
+              if (props.user.profile !== PROFILE.ADMINISTRATOR) return <Redirect to={{pathname: '/'}} />
29
+
28 30
               props.renderAppFullscreen({slug: 'admin_workspace_user', hexcolor: '#7d4e24', type: 'workspace'}, props.user, {})
29 31
               return null
30 32
             }} />
31 33
 
32 34
             <Route path={PAGE.ADMIN.USER} render={() => {
35
+              if (props.user.profile !== PROFILE.ADMINISTRATOR) return <Redirect to={{pathname: '/'}} />
36
+
33 37
               props.renderAppFullscreen({slug: 'admin_workspace_user', hexcolor: '#7d4e24', type: 'user'}, props.user, {})
34 38
               return null
35 39
             }} />
@@ -41,4 +45,4 @@ class AppFullscreenManager extends React.Component {
41 45
 }
42 46
 
43 47
 const mapStateToProps = ({ user }) => ({ user })
44
-export default connect(mapStateToProps)(withRouter(appFactory(AppFullscreenManager)))
48
+export default withRouter(connect(mapStateToProps)(appFactory(AppFullscreenRouter)))

+ 36 - 13
frontend/src/container/Dashboard.jsx View File

@@ -20,7 +20,7 @@ import {
20 20
   setWorkspaceDetail,
21 21
   setWorkspaceMemberList,
22 22
   setWorkspaceRecentActivityList,
23
-  setWorkspaceRecentActivityForUserList,
23
+  appendWorkspaceRecentActivityList,
24 24
   setWorkspaceReadStatusList
25 25
 } from '../action-creator.sync.js'
26 26
 import { ROLE, PAGE } from '../helper.js'
@@ -34,7 +34,7 @@ class Dashboard extends React.Component {
34 34
   constructor (props) {
35 35
     super(props)
36 36
     this.state = {
37
-      workspaceIdInUrl: props.match.params.idws ? parseInt(props.match.params.idws) : null, // this is used to avoid handling the parseInt everytime
37
+      workspaceIdInUrl: props.match.params.idws ? parseInt(props.match.params.idws) : null, // this is used to avoid handling the parseInt every time
38 38
       newMember: {
39 39
         id: '',
40 40
         avatarUrl: '',
@@ -51,6 +51,26 @@ class Dashboard extends React.Component {
51 51
   }
52 52
 
53 53
   async componentDidMount () {
54
+    this.loadWorkspaceDetail()
55
+    this.loadMemberList()
56
+    this.loadRecentActivity()
57
+  }
58
+
59
+  componentDidUpdate (prevProps, prevState) {
60
+    const { props, state } = this
61
+
62
+    if (prevProps.match.params.idws !== props.match.params.idws) {
63
+      this.setState({workspaceIdInUrl: props.match.params.idws ? parseInt(props.match.params.idws) : null})
64
+    }
65
+
66
+    if (prevState.workspaceIdInUrl !== state.workspaceIdInUrl) {
67
+      this.loadWorkspaceDetail()
68
+      this.loadMemberList()
69
+      this.loadRecentActivity()
70
+    }
71
+  }
72
+
73
+  loadWorkspaceDetail = async () => {
54 74
     const { props, state } = this
55 75
 
56 76
     const fetchWorkspaceDetail = await props.dispatch(getWorkspaceDetail(props.user, state.workspaceIdInUrl))
@@ -58,8 +78,6 @@ class Dashboard extends React.Component {
58 78
       case 200: props.dispatch(setWorkspaceDetail(fetchWorkspaceDetail.json)); break
59 79
       default: props.dispatch(newFlashMessage(`${props.t('An error has happened while fetching')} ${props.t('workspace detail')}`, 'warning')); break
60 80
     }
61
-    this.loadMemberList()
62
-    this.loadRecentActivity()
63 81
   }
64 82
 
65 83
   loadMemberList = async () => {
@@ -87,11 +105,6 @@ class Dashboard extends React.Component {
87 105
       case 200: props.dispatch(setWorkspaceReadStatusList(fetchWorkspaceReadStatusList.json)); break
88 106
       default: props.dispatch(newFlashMessage(`${props.t('An error has happened while fetching')} ${props.t('read status list')}`, 'warning')); break
89 107
     }
90
-
91
-    const readStatusForUserList = fetchWorkspaceReadStatusList.json.filter(c => c.read_by_user).map(c => c.content_id)
92
-    const recentActivityForUserList = fetchWorkspaceRecentActivityList.json.filter(content => !readStatusForUserList.includes(content.content_id))
93
-
94
-    props.dispatch(setWorkspaceRecentActivityForUserList(recentActivityForUserList))
95 108
   }
96 109
 
97 110
   handleToggleNewMemberDashboard = () => this.setState(prevState => ({displayNewMemberDashboard: !prevState.displayNewMemberDashboard}))
@@ -114,7 +127,15 @@ class Dashboard extends React.Component {
114 127
   }
115 128
 
116 129
   handleClickSeeMore = async () => {
117
-    console.log('nyi')
130
+    const { props, state } = this
131
+
132
+    const idLastRecentActivity = props.curWs.recentActivityList[props.curWs.recentActivityList.length - 1].id
133
+
134
+    const fetchWorkspaceRecentActivityList = await props.dispatch(getWorkspaceRecentActivityList(props.user, state.workspaceIdInUrl, idLastRecentActivity))
135
+    switch (fetchWorkspaceRecentActivityList.status) {
136
+      case 200: props.dispatch(appendWorkspaceRecentActivityList(fetchWorkspaceRecentActivityList.json)); break
137
+      default: props.dispatch(newFlashMessage(`${props.t('An error has happened while fetching')} ${props.t('recent activity list')}`, 'warning')); break
138
+    }
118 139
   }
119 140
 
120 141
   handleSearchUser = async userNameToSearch => {
@@ -180,7 +201,7 @@ class Dashboard extends React.Component {
180 201
     const { props, state } = this
181 202
 
182 203
     return (
183
-      <div className='Dashboard' style={{width: '100%'}}>
204
+      <div className='dashboard'>
184 205
         <PageWrapper customeClass='dashboard'>
185 206
           <PageTitle
186 207
             parentClass='dashboard__header'
@@ -197,7 +218,7 @@ class Dashboard extends React.Component {
197 218
           <PageContent>
198 219
             <div className='dashboard__workspace-wrapper'>
199 220
               <div className='dashboard__workspace'>
200
-                <div className='dashboard__workspace__title'>
221
+                <div className='dashboard__workspace__title primaryColorFont'>
201 222
                   {props.curWs.label}
202 223
                 </div>
203 224
 
@@ -223,6 +244,7 @@ class Dashboard extends React.Component {
223 244
                   label={ct.label}
224 245
                   faIcon={ct.faIcon}
225 246
                   creationLabel={ct.creationLabel}
247
+                  onClickBtn={() => props.history.push(PAGE.WORKSPACE.NEW(props.curWs.id, ct.slug))}
226 248
                   key={ct.label}
227 249
                 />
228 250
               )}
@@ -231,7 +253,8 @@ class Dashboard extends React.Component {
231 253
             <div className='dashboard__workspaceInfo'>
232 254
               <RecentActivity
233 255
                 customClass='dashboard__activity'
234
-                recentActivityFilteredForUser={props.curWs.recentActivityForUserList}
256
+                recentActivityList={props.curWs.recentActivityList}
257
+                readByUserList={props.curWs.contentReadStatusList}
235 258
                 contentTypeList={props.contentType}
236 259
                 onClickRecentContent={this.handleClickRecentContent}
237 260
                 onClickEverythingAsRead={this.handleClickMarkRecentActivityAsRead}

+ 11 - 4
frontend/src/container/Header.jsx View File

@@ -13,6 +13,7 @@ import MenuActionListItemDropdownLang from '../component/Header/MenuActionListIt
13 13
 import MenuActionListItemHelp from '../component/Header/MenuActionListItem/Help.jsx'
14 14
 import MenuActionListItemMenuProfil from '../component/Header/MenuActionListItem/MenuProfil.jsx'
15 15
 import MenuActionListItemNotification from '../component/Header/MenuActionListItem/Notification.jsx'
16
+import MenuActionListAdminLink from '../component/Header/MenuActionListItem/AdminLink.jsx'
16 17
 import logoHeader from '../img/logo-tracim.png'
17 18
 import {
18 19
   newFlashMessage,
@@ -22,7 +23,7 @@ import {
22 23
 import {
23 24
   postUserLogout
24 25
 } from '../action-creator.async.js'
25
-import { COOKIE, PAGE } from '../helper.js'
26
+import { COOKIE, PAGE, PROFILE } from '../helper.js'
26 27
 
27 28
 class Header extends React.Component {
28 29
   handleClickLogo = () => {}
@@ -65,9 +66,11 @@ class Header extends React.Component {
65 66
         <nav className='navbar navbar-expand-md navbar-light bg-light'>
66 67
           <Logo logoSrc={logoHeader} onClickImg={this.handleClickLogo} />
67 68
 
68
-          <div className='header__breadcrumb d-none d-lg-block ml-4'>
69
-            Dev Tracim - liste des contenus
70
-          </div>
69
+          { /*
70
+            <div className='header__breadcrumb d-none d-lg-block ml-4'>
71
+              Dev Tracim - liste des contenus
72
+            </div>
73
+          */ }
71 74
 
72 75
           <NavbarToggler />
73 76
 
@@ -84,6 +87,10 @@ class Header extends React.Component {
84 87
                 onClickSubmit={this.handleClickSubmit}
85 88
               />
86 89
 
90
+              {user.profile === PROFILE.ADMINISTRATOR &&
91
+                <MenuActionListAdminLink t={this.props.t} />
92
+              }
93
+
87 94
               <MenuActionListItemDropdownLang
88 95
                 langList={lang}
89 96
                 idLangActive={user.lang}

+ 33 - 24
frontend/src/container/Login.jsx View File

@@ -17,6 +17,7 @@ import {
17 17
   setUserConnected
18 18
 } from '../action-creator.sync.js'
19 19
 import { COOKIE, PAGE } from '../helper.js'
20
+import { Checkbox } from 'tracim_frontend_lib'
20 21
 
21 22
 class Login extends React.Component {
22 23
   constructor (props) {
@@ -36,7 +37,11 @@ class Login extends React.Component {
36 37
 
37 38
   handleChangeLogin = e => this.setState({inputLogin: {...this.state.inputLogin, value: e.target.value}})
38 39
   handleChangePassword = e => this.setState({inputPassword: {...this.state.inputPassword, value: e.target.value}})
39
-  handleChangeRememberMe = () => this.setState(prev => ({inputRememberMe: !prev.inputRememberMe}))
40
+  handleChangeRememberMe = e => {
41
+    e.preventDefault()
42
+    e.stopPropagation()
43
+    this.setState(prev => ({inputRememberMe: !prev.inputRememberMe}))
44
+  }
40 45
 
41 46
   handleClickSubmit = async () => {
42 47
     const { history, dispatch, t } = this.props
@@ -52,8 +57,13 @@ class Login extends React.Component {
52 57
         logged: true
53 58
       }))
54 59
 
55
-      Cookies.set(COOKIE.USER_LOGIN, inputLogin.value)
56
-      Cookies.set(COOKIE.USER_AUTH, userAuth)
60
+      if (inputRememberMe) {
61
+        Cookies.set(COOKIE.USER_LOGIN, inputLogin.value, {expires: 365})
62
+        Cookies.set(COOKIE.USER_AUTH, userAuth, {expires: 365})
63
+      } else {
64
+        Cookies.set(COOKIE.USER_LOGIN, inputLogin.value)
65
+        Cookies.set(COOKIE.USER_AUTH, userAuth)
66
+      }
57 67
 
58 68
       history.push(PAGE.WORKSPACE.ROOT)
59 69
     } else if (fetchPostUserLogin.status === 403) {
@@ -74,7 +84,7 @@ class Login extends React.Component {
74 84
               <div className='col-12 col-sm-11 col-md-8 col-lg-6 col-xl-4'>
75 85
 
76 86
                 <Card customClass='loginpage__connection'>
77
-                  <CardHeader customClass='connection__header text-center'>{this.props.t('Connection')}</CardHeader>
87
+                  <CardHeader customClass='connection__header primaryColorBgLighten text-center'>{this.props.t('Connection')}</CardHeader>
78 88
 
79 89
                   <CardBody formClass='connection__form'>
80 90
                     <div>
@@ -102,35 +112,34 @@ class Login extends React.Component {
102 112
                         onChange={this.handleChangePassword}
103 113
                       />
104 114
 
105
-                      <div className='row mt-4 mb-4'>
115
+                      <div className='row align-items-center mt-4 mb-4'>
106 116
                         <div className='col-12 col-sm-6 col-md-6 col-lg-6 col-xl-6'>
107
-                          {/*
108
-                          <InputCheckbox
109
-                            parentClassName='connection__form__rememberme'
110
-                            customClass=''
111
-                            label='Se souvenir de moi'
112
-                            checked={this.state.inputRememberMe}
113
-                            onChange={this.handleChangeRememberMe}
114
-                          />
115
-                          */}
116
-                        </div>
117
+                          <div className='connection__form__rememberme' onClick={this.handleChangeRememberMe}>
118
+                            <Checkbox
119
+                              name='inputRememberMe'
120
+                              checked={this.state.inputRememberMe}
121
+                            />
122
+                            Se souvenir de moi
123
+                          </div>
117 124
 
118
-                        <div className='col-12 col-sm-6 col-md-6 col-lg-6 col-xl-6 text-sm-right'>
119 125
                           <LoginBtnForgotPw
120 126
                             customClass='connection__form__pwforgot'
121 127
                             label={this.props.t('Forgotten password ?')}
122 128
                           />
123 129
                         </div>
124
-                      </div>
125 130
 
126
-                      <Button
127
-                        htmlType='button'
128
-                        bootstrapType='primary'
129
-                        customClass='connection__form__btnsubmit ml-auto'
130
-                        label={this.props.t('Connection')}
131
-                        onClick={this.handleClickSubmit}
132
-                      />
131
+                        <div className='col-6 col-sm-6 col-md-6 col-lg-6 col-xl-6'>
132
+                          <Button
133
+                            htmlType='button'
134
+                            bootstrapType='primary'
135
+                            customClass='connection__form__btnsubmit ml-auto'
136
+                            label={this.props.t('Connection')}
137
+                            onClick={this.handleClickSubmit}
138
+                          />
139
+                        </div>
140
+                      </div>
133 141
                     </div>
142
+
134 143
                   </CardBody>
135 144
                 </Card>
136 145
 

+ 1 - 1
frontend/src/container/ProgressBar.jsx View File

@@ -13,7 +13,7 @@ class ProgressBar extends Component {
13 13
               <span className='progress-right'>
14 14
                 <span className='progress-bar' />
15 15
               </span>
16
-              <div className='progress-value'>
16
+              <div className='progress-value primaryColorBg'>
17 17
                 90%
18 18
               </div>
19 19
             </div>

+ 38 - 39
frontend/src/container/Sidebar.jsx View File

@@ -83,6 +83,7 @@ class Sidebar extends React.Component {
83 83
     this.props.history.push(PAGE.WORKSPACE.CONTENT_LIST(idWs))
84 84
   }
85 85
 
86
+  // @DEPRECATED
86 87
   // not used, right now, link on sidebar filters is a <Link>
87 88
   handleClickContentFilter = (idWs, filter) => {
88 89
     const { workspace, history } = this.props
@@ -106,49 +107,47 @@ class Sidebar extends React.Component {
106 107
 
107 108
     return (
108 109
       <div className={classnames('sidebar primaryColorBgDarken', {'sidebarclose': sidebarClose})}>
109
-        <div className='sidebarSticky'>
110
-          <div className='sidebar__expand primaryColorBg' onClick={this.handleClickToggleSidebar}>
111
-            <i className={classnames('fa fa-chevron-left', {'fa-chevron-right': sidebarClose, 'fa-chevron-left': !sidebarClose})} />
112
-          </div>
113 110
 
114
-          <div className='sidebar__wrapper'>
115
-
116
-            <nav className='sidebar__navigation'>
117
-              <ul className='sidebar__navigation__workspace'>
118
-                { workspaceList.map(ws =>
119
-                  <WorkspaceListItem
120
-                    idWs={ws.id}
121
-                    label={ws.label}
122
-                    allowedApp={ws.sidebarEntry}
123
-                    lang={activeLang}
124
-                    activeFilterList={ws.id === workspaceIdInUrl ? [qs.parse(this.props.location.search).type] : []}
125
-                    isOpenInSidebar={ws.isOpenInSidebar}
126
-                    onClickTitle={() => this.handleClickWorkspace(ws.id, !ws.isOpenInSidebar)}
127
-                    onClickAllContent={this.handleClickAllContent}
128
-                    // onClickContentFilter={this.handleClickContentFilter}
129
-                    key={ws.id}
130
-                  />
131
-                )}
132
-              </ul>
133
-            </nav>
134
-
135
-            <div className='sidebar__btnnewworkspace'>
136
-              <button
137
-                className='sidebar__btnnewworkspace__btn btn btn-primary primaryColorBg primaryColorBorder primaryColorBorderDarkenHover mb-5'
138
-                onClick={this.handleClickNewWorkspace}
139
-              >
140
-                {t('Create a workspace')}
141
-              </button>
142
-            </div>
111
+        <div className='sidebar__expand primaryColorBg' onClick={this.handleClickToggleSidebar}>
112
+          <i className={classnames('fa fa-chevron-left', {'fa-chevron-right': sidebarClose, 'fa-chevron-left': !sidebarClose})} />
113
+        </div>
143 114
 
115
+        <div className='sidebar__content'>
116
+          <nav className={classnames('sidebar__content__navigation', {'sidebarclose': sidebarClose})}>
117
+            <ul className='sidebar__content__navigation__workspace'>
118
+              { workspaceList.map(ws =>
119
+                <WorkspaceListItem
120
+                  idWs={ws.id}
121
+                  label={ws.label}
122
+                  allowedApp={ws.sidebarEntry}
123
+                  lang={activeLang}
124
+                  activeFilterList={ws.id === workspaceIdInUrl ? [qs.parse(this.props.location.search).type] : []}
125
+                  isOpenInSidebar={ws.isOpenInSidebar}
126
+                  onClickTitle={() => this.handleClickWorkspace(ws.id, !ws.isOpenInSidebar)}
127
+                  onClickAllContent={this.handleClickAllContent}
128
+                  // onClickContentFilter={this.handleClickContentFilter}
129
+                  key={ws.id}
130
+                />
131
+              )}
132
+            </ul>
133
+          </nav>
134
+
135
+          <div className='sidebar__content__btnnewworkspace'>
136
+            <button
137
+              className='sidebar__content__btnnewworkspace__btn btn btn-primary primaryColorBg primaryColorBorder primaryColorBorderDarkenHover mb-5'
138
+              onClick={this.handleClickNewWorkspace}
139
+            >
140
+              {t('Create a workspace')}
141
+            </button>
144 142
           </div>
145 143
 
146
-          <div className='sidebar__footer mb-2'>
147
-            <div className='sidebar__footer__text whiteFontColor d-flex align-items-end justify-content-center'>
148
-              Copyright - 2013 - 2018
149
-              <div className='sidebar__footer__text__link'>
150
-                <a href='http://www.tracim.fr/' target='_blank' className='ml-3'>tracim.fr</a>
151
-              </div>
144
+        </div>
145
+
146
+        <div className='sidebar__footer mb-2'>
147
+          <div className='sidebar__footer__text whiteFontColor d-flex align-items-end justify-content-center'>
148
+            Copyright - 2013 - 2018
149
+            <div className='sidebar__footer__text__link'>
150
+              <a href='http://www.tracim.fr/' target='_blank' className='ml-3'>tracim.fr</a>
152 151
             </div>
153 152
           </div>
154 153
         </div>

+ 11 - 7
frontend/src/container/Tracim.jsx View File

@@ -5,8 +5,7 @@ import Sidebar from './Sidebar.jsx'
5 5
 import Header from './Header.jsx'
6 6
 import Login from './Login.jsx'
7 7
 import Account from './Account.jsx'
8
-import AdminWorkspacePage from './AdminWorkspacePage.jsx'
9
-import AppFullscreenManager from './AppFullscreenManager.jsx'
8
+import AppFullscreenRouter from './AppFullscreenRouter.jsx'
10 9
 import FlashMessage from '../component/FlashMessage.jsx'
11 10
 import WorkspaceContent from './WorkspaceContent.jsx'
12 11
 import WIPcomponent from './WIPcomponent.jsx'
@@ -18,6 +17,7 @@ import {
18 17
   getUserIsConnected
19 18
 } from '../action-creator.async.js'
20 19
 import {
20
+  newFlashMessage,
21 21
   removeFlashMessage,
22 22
   setUserConnected
23 23
 } from '../action-creator.sync.js'
@@ -37,6 +37,10 @@ class Tracim extends React.Component {
37 37
         console.log('%c<Tracim> Custom event', 'color: #28a745', type, data)
38 38
         this.props.history.push(data.url)
39 39
         break
40
+      case 'addFlashMsg':
41
+        console.log('%c<Tracim> Custom event', 'color: #28a745', type, data)
42
+        this.props.dispatch(newFlashMessage(data.msg, data.type, data.delay))
43
+        break
40 44
     }
41 45
   }
42 46
 
@@ -101,11 +105,11 @@ class Tracim extends React.Component {
101 105
 
102 106
               <Route exact path={PAGE.WORKSPACE.ROOT} render={() => props.workspaceList.length === 0 // handle '/' and redirect to first workspace
103 107
                 ? null // @FIXME this needs to be handled in case of new user that has no workspace
104
-                : <Redirect to={{pathname: `/workspaces/${props.workspaceList[0].id}/contents`, state: {from: props.location}}} />
108
+                : <Redirect to={{pathname: PAGE.WORKSPACE.DASHBOARD(props.workspaceList[0].id), state: {from: props.location}}} />
105 109
               } />
106 110
 
107 111
               <Route exact path={`${PAGE.WORKSPACE.ROOT}/:idws`} render={props2 => // handle '/workspaces/:id' and add '/contents'
108
-                <Redirect to={{pathname: `/workspaces/${props2.match.params.idws}/contents`, state: {from: props.location}}} />
112
+                <Redirect to={{pathname: PAGE.WORKSPACE.CONTENT_LIST(props2.match.params.idws), state: {from: props.location}}} />
109 113
               } />
110 114
 
111 115
               <Route path={PAGE.WORKSPACE.DASHBOARD(':idws')} component={Dashboard} />
@@ -125,15 +129,15 @@ class Tracim extends React.Component {
125 129
           <Route path={PAGE.ADMIN.ROOT} render={() =>
126 130
             <div className='sidebarpagecontainer'>
127 131
               <Sidebar />
128
-              <AppFullscreenManager />
132
+
133
+              <AppFullscreenRouter />
129 134
             </div>
130 135
           } />
131 136
 
132
-          <Route path='/admin_temp/workspace' component={AdminWorkspacePage} />
133
-
134 137
           <Route path={'/wip/:cp'} component={WIPcomponent} /> {/* for testing purpose only */}
135 138
 
136 139
           <div id='appFeatureContainer' />
140
+          <div id='popupCreateContentContainer' />
137 141
         </div>
138 142
 
139 143
       </div>

+ 30 - 8
frontend/src/container/WorkspaceContent.jsx View File

@@ -16,11 +16,15 @@ import {
16 16
 } from 'tracim_frontend_lib'
17 17
 import {
18 18
   getWorkspaceContentList,
19
-  getFolderContent
19
+  getFolderContent,
20
+  putWorkspaceContentArchived,
21
+  putWorkspaceContentDeleted
20 22
 } from '../action-creator.async.js'
21 23
 import {
22 24
   newFlashMessage,
23
-  setWorkspaceContentList
25
+  setWorkspaceContentList,
26
+  setWorkspaceContentArchived,
27
+  setWorkspaceContentDeleted
24 28
 } from '../action-creator.sync.js'
25 29
 
26 30
 const qs = require('query-string')
@@ -125,14 +129,34 @@ class WorkspaceContent extends React.Component {
125 129
     console.log('%c<WorkspaceContent> download nyi', 'color: #c17838', content)
126 130
   }
127 131
 
128
-  handleClickArchiveContentItem = (e, content) => {
132
+  handleClickArchiveContentItem = async (e, content) => {
133
+    const { props, state } = this
134
+
129 135
     e.stopPropagation()
130
-    console.log('%c<WorkspaceContent> archive nyi', 'color: #c17838', content)
136
+
137
+    const fetchPutContentArchived = await props.dispatch(putWorkspaceContentArchived(props.user, content.idWorkspace, content.id))
138
+    switch (fetchPutContentArchived.status) {
139
+      case 204:
140
+        props.dispatch(setWorkspaceContentArchived(content.idWorkspace, content.id))
141
+        this.loadContentList(state.workspaceIdInUrl)
142
+        break
143
+      default: props.dispatch(newFlashMessage(props.t('Error while archiving document')))
144
+    }
131 145
   }
132 146
 
133
-  handleClickDeleteContentItem = (e, content) => {
147
+  handleClickDeleteContentItem = async (e, content) => {
148
+    const { props, state } = this
149
+
134 150
     e.stopPropagation()
135
-    console.log('%c<WorkspaceContent> delete nyi', 'color: #c17838', content)
151
+
152
+    const fetchPutContentDeleted = await props.dispatch(putWorkspaceContentDeleted(props.user, content.idWorkspace, content.id))
153
+    switch (fetchPutContentDeleted.status) {
154
+      case 204:
155
+        props.dispatch(setWorkspaceContentDeleted(content.idWorkspace, content.id))
156
+        this.loadContentList(state.workspaceIdInUrl)
157
+        break
158
+      default: props.dispatch(newFlashMessage(props.t('Error while deleting document')))
159
+    }
136 160
   }
137 161
 
138 162
   handleClickFolder = folderId => {
@@ -197,8 +221,6 @@ class WorkspaceContent extends React.Component {
197 221
           </PageTitle>
198 222
 
199 223
           <PageContent parentClass='workspace__content'>
200
-            <div id='popupCreateContentContainer' />
201
-
202 224
             <div className='workspace__content__fileandfolder folder__content active'>
203 225
               <ContentItemHeader />
204 226
 

+ 6 - 5
frontend/src/css/AccountPage.styl View File

@@ -42,6 +42,8 @@ settingText()
42 42
   width 30px
43 43
 
44 44
 .account
45
+  margin 45px 0 0 0
46
+  width 100%
45 47
   .btn-outline-primary
46 48
     color thirdColor
47 49
     border-color thirdColor
@@ -67,11 +69,8 @@ settingText()
67 69
         height 150px
68 70
     &__name
69 71
       font-size 22px
70
-    &__email
71
-      color thirdColor
72 72
     &__company
73 73
       font-size 20px
74
-      color thirdColor
75 74
   &__delimiter
76 75
     position relative
77 76
     top 3px
@@ -131,11 +130,13 @@ settingText()
131 130
           &__txtinput
132 131
             display block
133 132
             width auto
134
-            border 1px solid thirdColor
133
+            border-width 1px
134
+            border-style solid
135 135
             border-radius 5px
136 136
           &__button
137 137
             vertical-align top
138
-            border 1px solid thirdColor
138
+            border-width 1px
139
+            border-style solid
139 140
             border-radius 5px
140 141
             padding 8px 25px
141 142
             cursor pointer

+ 0 - 11
frontend/src/css/AdminWorkspacePage.styl View File

@@ -1,11 +0,0 @@
1
-.adminWorkspacePage
2
-  &__createworkspace
3
-    &__btncreate
4
-      margin 25px 15px
5
-  &__description
6
-    margin 25px 15px
7
-    font-size 20px
8
-  &__delimiter
9
-    margin 25px auto 65px auto
10
-  &__workspaceTable
11
-    margin 25px 15px

+ 22 - 12
frontend/src/css/Dashboard.styl View File

@@ -60,36 +60,46 @@ flexwrap()
60 60
     &__calltoaction
61 61
       justify-content center
62 62
 
63
+
64
+/**** MEDIAQUERIES ****/
65
+
63 66
 /**** MEDIA 992px & 1199px ****/
64 67
 
65 68
 @media (min-width: min-lg) and (max-width: max-lg)
66 69
 
67 70
   .dashboard
68
-    margin-left 15px
71
+    &__workspace
72
+      width 100%
73
+
74
+/**** MEDIA 768px & 991px ****/
75
+
76
+@media (min-width: min-md) and (max-width: max-md)
77
+
78
+  .dashboard
79
+    &__workspace
80
+      width 100%
69 81
 
70 82
 /**** MEDIA 576px & 767px ****/
71 83
 
72 84
 @media (min-width: min-sm) and (max-width: max-sm)
73 85
 
74 86
   .dashboard
75
-    &__activity
76
-      margin 25px 15px 25px 0
87
+    &__workspace
88
+      width 100%
77 89
 
78 90
 /**** MEDIA 575px ****/
79 91
 
80 92
 @media (max-width: max-xs)
81 93
 
82 94
   .dashboard
83
-    margin-left 0
84
-    &__title
85
-      margin-left 10px
95
+    &__header
96
+      &__advancedmode
97
+        margin 25px 15px
86 98
     &__workspace
87
-      margin-left 10px
88
-      width auto
89
-    &__userstatut
90
-      margin-left 10px
91
-      width auto
99
+      width 100%
92 100
     &__calltoaction
101
+      flex-direction column
102
+      align-items center
93 103
       justify-content center
94 104
       &__button
95
-        margin 10px
105
+        margin 20px 0

+ 2 - 2
frontend/src/css/Generic.styl View File

@@ -202,10 +202,10 @@ a
202 202
   &__btn
203 203
     display flex
204 204
     margin-bottom 30px
205
-    border 1px solid thirdColor
205
+    border-width 1px
206
+    border-style solid
206 207
     border-radius 10px
207 208
     padding 15px 25px
208
-    color thirdColor
209 209
     cursor pointer
210 210
     &:hover, &:focus
211 211
       background-color thirdColor

+ 15 - 3
frontend/src/css/Header.styl View File

@@ -24,7 +24,21 @@
24 24
       display flex
25 25
       margin-top 15px
26 26
       list-style none
27
+      &__adminlink
28
+        .adminlink
29
+          &__btn
30
+            margin-right 15px
31
+            border 1px solid grey
32
+            border-radius 5px
33
+            background-color transparent
34
+            &::after
35
+              margin-left 15px
36
+          &__setting
37
+            padding 0
38
+            .setting__link
39
+              padding 10px
27 40
       &__itemsearch
41
+        display none
28 42
         margin-right 8%
29 43
         width 55%
30 44
         &__search
@@ -32,9 +46,6 @@
32 46
           .search__addonsearch
33 47
             background-color transparent
34 48
             cursor pointer
35
-            &:hover
36
-              background-color thirdColor
37
-              color mainColor
38 49
       .btnnavbar
39 50
         border 1px solid grey
40 51
         background-color transparent
@@ -98,6 +109,7 @@
98 109
             padding 0
99 110
             left inherit
100 111
             right 0
112
+            width 100%
101 113
             cursor pointer
102 114
             .setting__link
103 115
               padding 10px

+ 9 - 13
frontend/src/css/Login.styl View File

@@ -15,13 +15,17 @@
15 15
     border none
16 16
     box-shadow shadow-right
17 17
     .connection__header
18
-      background-color thirdColor
19
-      color #FFF
18
+      color off-white
20 19
       font-size 25px
21 20
   .connection__form
22 21
     &__rememberme
23
-      &__label
24
-        font-size 13px
22
+      margin-bottom 10px
23
+      font-size 14px
24
+      line-height 23px
25
+      cursor pointer
26
+      label
27
+        margin-right 8px
28
+        top 4px
25 29
     &__groupemail
26 30
       position relative
27 31
       &__icon
@@ -43,6 +47,7 @@
43 47
         padding-left 45px
44 48
     &__btnsubmit
45 49
       display block
50
+      margin-right 15px
46 51
       border none
47 52
       width 150px
48 53
       background-color green
@@ -52,17 +57,8 @@
52 57
       &:focus
53 58
         box-shadow shadow-all-side-green
54 59
     &__pwforgot
55
-      margin-top 3px
56 60
       cursor pointer
57 61
       font-size 13px
58
-      &:hover::after
59
-        position absolute
60
-        top 20px
61
-        right 15px
62
-        border-bottom 1px solid darkGrey
63
-        padding-bottom 2px
64
-        content ' '
65
-        width 130px
66 62
   &__footer
67 63
     position fixed
68 64
     bottom 2%

+ 0 - 1
frontend/src/css/ProgressBar.styl View File

@@ -64,7 +64,6 @@
64 64
     height 90%
65 65
     border 10px solid off-white
66 66
     border-radius 50%
67
-    background darkBlue
68 67
     font-size 24px
69 68
     color off-white
70 69
     line-height 135px

+ 94 - 74
frontend/src/css/Sidebar.styl View File

@@ -1,4 +1,4 @@
1
-sidebar-width = 280px
1
+sidebar-width = 300px
2 2
 sidebar-animate-speed = 0.5s
3 3
 
4 4
 .sidebarSticky
@@ -15,6 +15,9 @@ leftside()
15 15
   background-color rgba(253, 253, 253, 0.3)
16 16
 
17 17
 .sidebar
18
+  display flex
19
+  flex-direction column
20
+  justify-content space-between
18 21
   position relative
19 22
   transition all sidebar-animate-speed
20 23
   width sidebar-width
@@ -26,8 +29,7 @@ leftside()
26 29
     width 0
27 30
   &__expand
28 31
     position absolute
29
-    top 0
30
-    right -43px
32
+    right -42px
31 33
     display flex
32 34
     justify-content center
33 35
     align-items center
@@ -38,73 +40,83 @@ leftside()
38 40
     cursor pointer
39 41
     color white
40 42
     transition all sidebar-animate-speed
41
-  &__btnnewworkspace
42
-    margin 50px 0
43
-    overflow hidden
44
-    &__btn
45
-      display block
46
-      margin 0 auto
47
-      padding 15px 30px
48
-  &__navigation
49
-    padding 0
50
-    overflow hidden
51
-    &__workspace
52
-      padding-left 0
53
-      list-style none
54
-      &__item
55
-        &__wrapper
56
-          display flex
57
-          align-items center
58
-          border-bottom 1px solid
59
-          width 100%
60
-          height 100%
61
-          cursor pointer
62
-        &__number
63
-          display flex
64
-          leftside()
65
-          padding 12px
66
-          width 50px
67
-          letter-spacing 2px
68
-        &__name
69
-          padding 10px
70
-          font-size 20px
71
-          color off-white
72
-          white-space nowrap
73
-          overflow hidden
74
-          text-overflow ellipsis
75
-        &__icon
76
-          margin 0 10px 0 auto
77
-          color white
78
-        &__submenu
79
-          margin 0
80
-          padding 0
81
-          width 100%
82
-          overflow hidden
83
-          & > li
84
-            display block
85
-          &__dropdown
43
+  &__content
44
+    height 100%
45
+    &__btnnewworkspace
46
+      margin 50px 0
47
+      overflow hidden
48
+      &__btn
49
+        display block
50
+        margin 0 auto
51
+        padding 15px 30px
52
+    &__navigation
53
+      padding 0
54
+      width sidebar-width
55
+      transition all sidebar-animate-speed
56
+      overflow hidden
57
+      &.sidebarclose
58
+        width 0
59
+      &__workspace
60
+        padding-left 0
61
+        list-style none
62
+        &__item
63
+          &__wrapper
86 64
             display flex
87 65
             align-items center
88 66
             border-bottom 1px solid
67
+            width 100%
68
+            height 100%
89 69
             cursor pointer
90
-            &__showdropdown
70
+          &__number
71
+            display flex
72
+            leftside()
73
+            padding 12px
74
+            width 50px
75
+            letter-spacing 2px
76
+          &__name
77
+            padding 10px
78
+            width 224px
79
+            font-size 20px
80
+            color off-white
81
+            white-space nowrap
82
+            overflow hidden
83
+            text-overflow ellipsis
84
+          &__icon
85
+            display flex
86
+            align-items center
87
+            width 26px
88
+            height 50px
89
+            color white
90
+          &__submenu
91
+            margin 0
92
+            padding 0
93
+            width 100%
94
+            overflow hidden
95
+            & > li
96
+              display block
97
+            &__dropdown
91 98
               display flex
92
-              justify-content space-between
93 99
               align-items center
94
-              padding 0 10px
95
-              width 100%
96
-            .dropdown__icon
97
-              padding 10px 15px
98
-              min-width 50px
99
-              leftside()
100
-            .dropdown__title
101
-              white-space nowrap
102
-              overflow hidden
103
-              text-overflow ellipsis
104
-              // color off-white
105
-            &.activeFilter
100
+              border-bottom 1px solid
101
+              cursor pointer
102
+              &__showdropdown
103
+                display flex
104
+                justify-content space-between
105
+                align-items center
106
+                padding 0 10px
107
+                width 100%
106 108
               .dropdown__icon
107
-                background-color rgba(253, 253, 253, 0.8)
109
+                padding 10px 15px
110
+                min-width 50px
111
+                leftside()
112
+              .dropdown__title
113
+                white-space nowrap
114
+                overflow hidden
115
+                text-overflow ellipsis
116
+                // color off-white
117
+              &.activeFilter
118
+                .dropdown__icon
119
+                  background-color rgba(253, 253, 253, 0.8)
108 120
   &__footer
109 121
     &__text
110 122
       color off-white
@@ -117,39 +129,47 @@ leftside()
117 129
             text-decoration underline
118 130
             color fourthColor
119 131
 
132
+
133
+/***** MEDIAQUERIES ******/
134
+
120 135
 /***** MEDIA 992px and 1199px ******/
121 136
 
122 137
 @media (min-width: min-lg) and (max-width: max-lg)
123 138
 
139
+  .sidebarpagecontainer
140
+    position relative
141
+
124 142
   .sidebar
125
-    position fixed
143
+    position absolute
126 144
 
127
-/***** MEDIA 576px and 991px ******/
145
+/*** MEDIA 768px and 991px ****/
128 146
 
129
-@media (min-width: min-sm) and (max-width: max-md)
147
+@media (min-width: min-md) and (max-width: max-md)
130 148
 
131
-  .sidebarSticky
132
-    top 69px
149
+  .sidebarpagecontainer
150
+    position relative
133 151
 
134 152
   .sidebar
135
-    position fixed
153
+    position absolute
136 154
 
137 155
 /***** MEDIA 576px and 767px *****/
138 156
 
139 157
 @media (min-width: min-sm) and (max-width: max-sm)
140 158
 
141 159
   .sidebarpagecontainer
160
+    position relative
142 161
     padding-top 69px
143 162
 
144
-/***** MEDIA  *****/
163
+  .sidebar
164
+    position absolute
165
+
166
+/***** MEDIA 575px *****/
145 167
 
146 168
 @media (max-width: 575px)
147 169
 
148 170
   .sidebarpagecontainer
171
+    position relative
149 172
     padding-top 69px
150 173
 
151
-  .sidebarSticky
152
-    top 69px
153
-
154 174
   .sidebar
155
-    position fixed
175
+    position absolute

+ 0 - 2
frontend/src/css/index.styl View File

@@ -30,5 +30,3 @@ html, body, #content
30 30
 @import 'HomepageCard'
31 31
 
32 32
 @import 'ExtandedAction'
33
-
34
-@import 'AdminWorkspacePage'

+ 6 - 1
frontend/src/helper.js View File

@@ -37,7 +37,7 @@ export const PAGE = {
37 37
   ADMIN: {
38 38
     ROOT: '/admin',
39 39
     WORKSPACE: '/admin/workspace',
40
-    USEr: '/admin/user'
40
+    USER: '/admin/user'
41 41
   }
42 42
 }
43 43
 
@@ -67,4 +67,9 @@ export const ROLE = [{
67 67
   label: 'Workspace manager'
68 68
 }]
69 69
 
70
+export const PROFILE = {
71
+  ADMINISTRATOR: 'administrators',
72
+  USER: 'users'
73
+}
74
+
70 75
 export const handleRouteFromApi = route => route.startsWith('/#') ? route.slice(2) : route

+ 22 - 15
frontend/src/reducer/currentWorkspace.js View File

@@ -1,11 +1,13 @@
1 1
 import {
2 2
   SET,
3
+  APPEND,
3 4
   WORKSPACE_DETAIL,
4 5
   WORKSPACE_MEMBER_LIST,
5
-  WORKSPACE_READ_STATUS_LIST, WORKSPACE_RECENT_ACTIVITY_FOR_USER_LIST,
6
+  WORKSPACE_READ_STATUS_LIST,
6 7
   WORKSPACE_RECENT_ACTIVITY_LIST
7 8
 } from '../action-creator.sync.js'
8 9
 import { handleRouteFromApi } from '../helper.js'
10
+import { generateAvatarFromPublicName } from 'tracim_frontend_lib'
9 11
 
10 12
 const defaultWorkspace = {
11 13
   id: 0,
@@ -43,7 +45,9 @@ export default function currentWorkspace (state = defaultWorkspace, action) {
43 45
         memberList: action.workspaceMemberList.map(m => ({
44 46
           id: m.user_id,
45 47
           publicName: m.user.public_name,
46
-          avatarUrl: m.user.avatar_url,
48
+          avatarUrl: m.user.avatar_url
49
+            ? m.user.avatar_url
50
+            : m.user.public_name ? generateAvatarFromPublicName(m.user.public_name) : '',
47 51
           role: m.role,
48 52
           isActive: m.is_active
49 53
         }))
@@ -66,21 +70,24 @@ export default function currentWorkspace (state = defaultWorkspace, action) {
66 70
         }))
67 71
       }
68 72
 
69
-    case `${SET}/${WORKSPACE_RECENT_ACTIVITY_FOR_USER_LIST}`:
73
+    case `${APPEND}/${WORKSPACE_RECENT_ACTIVITY_LIST}`:
70 74
       return {
71 75
         ...state,
72
-        recentActivityForUserList: action.workspaceRecentActivityForUserList.map(ra => ({
73
-          id: ra.content_id,
74
-          slug: ra.slug,
75
-          label: ra.label,
76
-          type: ra.content_type,
77
-          idParent: ra.parent_id,
78
-          showInUi: ra.show_in_ui,
79
-          isArchived: ra.is_archived,
80
-          isDeleted: ra.is_deleted,
81
-          statusSlug: ra.status,
82
-          subContentTypeSlug: ra.sub_content_types
83
-        }))
76
+        recentActivityList: [
77
+          ...state.recentActivityList,
78
+          ...action.workspaceRecentActivityList.map(ra => ({
79
+            id: ra.content_id,
80
+            slug: ra.slug,
81
+            label: ra.label,
82
+            type: ra.content_type,
83
+            idParent: ra.parent_id,
84
+            showInUi: ra.show_in_ui,
85
+            isArchived: ra.is_archived,
86
+            isDeleted: ra.is_deleted,
87
+            statusSlug: ra.status,
88
+            subContentTypeSlug: ra.sub_content_types
89
+          }))
90
+        ]
84 91
       }
85 92
 
86 93
     case `${SET}/${WORKSPACE_READ_STATUS_LIST}`:

+ 4 - 1
frontend/src/reducer/user.js View File

@@ -6,6 +6,7 @@ import {
6 6
   USER_DATA,
7 7
   USER_LANG
8 8
 } from '../action-creator.sync.js'
9
+import { generateAvatarFromPublicName } from 'tracim_frontend_lib'
9 10
 
10 11
 const defaultUser = {
11 12
   user_id: -1,
@@ -31,7 +32,9 @@ export default function user (state = defaultUser, action) {
31 32
       return {
32 33
         ...state,
33 34
         ...action.user,
34
-        avatar_url: 'https://www.algoo.fr/static/images/people_images/PERSO_SEUL.png' // @FIXME use avatar from api when db handles it
35
+        avatar_url: action.user.avatar_url
36
+          ? action.user.avatar_url
37
+          : action.user.public_name ? generateAvatarFromPublicName(action.user.public_name) : ''
35 38
       }
36 39
 
37 40
     case `${SET}/${USER_DISCONNECTED}`:

+ 30 - 14
frontend/src/reducer/workspaceContentList.js View File

@@ -3,25 +3,29 @@ import {
3 3
   UPDATE,
4 4
   WORKSPACE,
5 5
   WORKSPACE_CONTENT,
6
-  FOLDER
6
+  FOLDER,
7
+  WORKSPACE_CONTENT_ARCHIVED,
8
+  WORKSPACE_CONTENT_DELETED
7 9
 } from '../action-creator.sync.js'
8 10
 
9 11
 export default function workspaceContentList (state = [], action) {
10 12
   switch (action.type) {
11 13
     case `${SET}/${WORKSPACE_CONTENT}`:
12
-      return action.workspaceContentList.map(wsc => ({
13
-        id: wsc.content_id,
14
-        label: wsc.label,
15
-        slug: wsc.slug,
16
-        type: wsc.content_type,
17
-        idWorkspace: wsc.workspace_id,
18
-        isArchived: wsc.is_archived,
19
-        idParent: wsc.parent_id,
20
-        isDeleted: wsc.is_deleted,
21
-        showInUi: wsc.show_in_ui,
22
-        statusSlug: wsc.status,
23
-        subContentTypeSlug: wsc.sub_content_type_slug
24
-      }))
14
+      return action.workspaceContentList
15
+        .sort((a, b) => a.slug < b.slug ? -1 : 1)
16
+        .map(wsc => ({
17
+          id: wsc.content_id,
18
+          label: wsc.label,
19
+          slug: wsc.slug,
20
+          type: wsc.content_type,
21
+          idWorkspace: wsc.workspace_id,
22
+          isArchived: wsc.is_archived,
23
+          idParent: wsc.parent_id,
24
+          isDeleted: wsc.is_deleted,
25
+          showInUi: wsc.show_in_ui,
26
+          statusSlug: wsc.status,
27
+          subContentTypeSlug: wsc.sub_content_type_slug
28
+        }))
25 29
 
26 30
     case `${UPDATE}/${WORKSPACE}/Filter`: // not used anymore ?
27 31
       return {...state, filter: action.filterList}
@@ -40,6 +44,18 @@ export default function workspaceContentList (state = [], action) {
40 44
         content: state.content.map(c => setFolderContent(c, action))
41 45
       }
42 46
 
47
+    case `${SET}/${WORKSPACE_CONTENT_ARCHIVED}`:
48
+      return state.map(wsc => wsc.idWorkspace === action.idWorkspace && wsc.id === action.idContent
49
+        ? {...wsc, isArchived: true}
50
+        : wsc
51
+      )
52
+
53
+    case `${SET}/${WORKSPACE_CONTENT_DELETED}`:
54
+      return state.map(wsc => wsc.idWorkspace === action.idWorkspace && wsc.id === action.idContent
55
+        ? {...wsc, isDeleted: true}
56
+        : wsc
57
+      )
58
+
43 59
     default:
44 60
       return state
45 61
   }

+ 0 - 1
frontend_app_admin_workspace_user/dist/dev View File

@@ -1 +0,0 @@
1
-../../frontend/dist/dev

+ 0 - 1
frontend_app_admin_workspace_user/dist/font View File

@@ -1 +0,0 @@
1
-../../frontend/dist/font

+ 5 - 5
frontend_app_admin_workspace_user/dist/index.html View File

@@ -6,14 +6,14 @@
6 6
   <title>Html-document App Tracim</title>
7 7
   <link rel='shortcut icon' href='favicon.ico'>
8 8
 
9
-  <link rel="stylesheet" type="text/css" href="./font/font-awesome-4.7.0/css/font-awesome.css">
9
+  <link rel="stylesheet" type="text/css" href="./asset/font/font-awesome-4.7.0/css/font-awesome.css">
10 10
   <link href="https://fonts.googleapis.com/css?family=Quicksand:300,400,500,700" rel="stylesheet">
11
-  <link rel="stylesheet" type="text/css" href="./dev/bootstrap-4.0.0-beta.css">
11
+  <link rel="stylesheet" type="text/css" href="./asset/bootstrap/bootstrap-4.0.0-beta.css">
12 12
 </head>
13 13
 <body>
14
-  <script src="./dev/jquery-3.2.1.js"></script>
15
-  <script src="./dev/popper-1.12.3.js"></script>
16
-  <script src="./dev/bootstrap-4.0.0-beta.2.js"></script>
14
+  <script src="./asset/bootstrap/jquery-3.2.1.js"></script>
15
+  <script src="./asset/bootstrap/popper-1.12.3.js"></script>
16
+  <script src="./asset/bootstrap/bootstrap-4.0.0-beta.2.js"></script>
17 17
 
18 18
   <div id='content'></div>
19 19
 

+ 194 - 0
frontend_app_admin_workspace_user/src/component/AdminWorkspace.jsx View File

@@ -0,0 +1,194 @@
1
+import React from 'react'
2
+import {
3
+  Delimiter,
4
+  PageWrapper,
5
+  PageTitle,
6
+  PageContent
7
+} from 'tracim_frontend_lib'
8
+
9
+export class AdminWorkspace extends React.Component {
10
+  render () {
11
+    return (
12
+      <PageWrapper customClass='adminWorkspacePage'>
13
+        <PageTitle
14
+          parentClass={'adminWorkspacePage'}
15
+          title={'Workspace management'}
16
+        />
17
+
18
+        <PageContent parentClass='adminWorkspacePage'>
19
+
20
+          <div className='adminWorkspacePage__description'>
21
+            List of every workspaces
22
+          </div>
23
+
24
+          { /*
25
+            Alexi Cauvin 08/08/2018 => desactivate create workspace button due to redundancy
26
+
27
+            <div className='adminWorkspacePage__createworkspace'>
28
+              <button className='adminWorkspacePage__createworkspace__btncreate btn btn-primary primaryColorBg primaryColorBorder primaryColorBorderDarkenHover'>
29
+                {this.props.t('Create a workspace')}
30
+              </button>
31
+            </div>
32
+          */ }
33
+
34
+          <Delimiter customClass={'adminWorkspacePage__delimiter'} />
35
+
36
+          <div className='adminWorkspacePage__workspaceTable'>
37
+
38
+            <table className='table'>
39
+              <thead>
40
+                <tr>
41
+                  <th scope='col'>ID</th>
42
+                  <th scope='col'>Workspace</th>
43
+                  <th scope='col'>Description</th>
44
+                  <th scope='col'>Member count</th>
45
+                  { /*
46
+                      <th scope='col'>Calendar</th>
47
+                    */ }
48
+                  <th scope='col'>Delete workspace</th>
49
+                </tr>
50
+              </thead>
51
+              <tbody>
52
+                <tr>
53
+                  <th>1</th>
54
+                  <td>Design v_2</td>
55
+                  <td>Workspace about tracim v2 design</td>
56
+                  { /*
57
+                      <td className='d-flex align-items-center flex-wrap'>
58
+                        <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
59
+                          <i className='fa fa-fw fa-check-square-o' />
60
+                        </div>
61
+                        Enable
62
+                      </td>
63
+                    */ }
64
+                  <td>8</td>
65
+                  <td>
66
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
67
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
68
+                        <i className='fa fa-fw fa-trash-o' />
69
+                      </div>
70
+                      Delete
71
+                    </div>
72
+                  </td>
73
+                </tr>
74
+                <tr>
75
+                  <th>2</th>
76
+                  <td>New features</td>
77
+                  <td>Add a new features : Annotated files</td>
78
+                  { /*
79
+                      <td className='d-flex align-items-center flex-wrap'>
80
+                        <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
81
+                          <i className='fa fa-fw fa-square-o' />
82
+                        </div>
83
+                        Disable
84
+                      </td>
85
+                    */ }
86
+                  <td>5</td>
87
+                  <td>
88
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
89
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
90
+                        <i className='fa fa-fw fa-trash-o' />
91
+                      </div>
92
+                      Delete
93
+                    </div>
94
+                  </td>
95
+                </tr>
96
+                <tr>
97
+                  <th>3</th>
98
+                  <td>Fix Backend</td>
99
+                  <td>workspace referring to multiple issues on the backend </td>
100
+                  { /*
101
+                      <td className='d-flex align-items-center flex-wrap'>
102
+                        <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
103
+                          <i className='fa fa-fw fa-check-square-o' />
104
+                        </div>
105
+                        Enable
106
+                      </td>
107
+                    */ }
108
+                  <td>3</td>
109
+                  <td>
110
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
111
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
112
+                        <i className='fa fa-fw fa-trash-o' />
113
+                      </div>
114
+                      Delete
115
+                    </div>
116
+                  </td>
117
+                </tr>
118
+                <tr>
119
+                  <th>4</th>
120
+                  <td>Design v_2</td>
121
+                  <td>Workspace about tracim v2 design</td>
122
+                  { /*
123
+                      <td className='d-flex align-items-center flex-wrap'>
124
+                        <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
125
+                          <i className='fa fa-fw fa-square-o' />
126
+                        </div>
127
+                        Disable
128
+                      </td>
129
+                    */ }
130
+                  <td>8</td>
131
+                  <td>
132
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
133
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
134
+                        <i className='fa fa-fw fa-trash-o' />
135
+                      </div>
136
+                      Delete
137
+                    </div>
138
+                  </td>
139
+                </tr>
140
+                <tr>
141
+                  <th>5</th>
142
+                  <td>New features</td>
143
+                  <td>Add a new features : Annotated files</td>
144
+                  { /*
145
+                      <td className='d-flex align-items-center flex-wrap'>
146
+                        <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
147
+                          <i className='fa fa-fw fa-square-o' />
148
+                        </div>
149
+                        Disable
150
+                      </td>
151
+                    */ }
152
+                  <td>5</td>
153
+                  <td>
154
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
155
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
156
+                        <i className='fa fa-fw fa-trash-o' />
157
+                      </div>
158
+                      Delete
159
+                    </div>
160
+                  </td>
161
+                </tr>
162
+                <tr>
163
+                  <th>6</th>
164
+                  <td>Fix Backend</td>
165
+                  <td>workspace referring to multiple issues on the backend </td>
166
+                  { /*
167
+                      <td className='d-flex align-items-center flex-wrap'>
168
+                        <div className='adminWorkspacePage__workspaceTable__calendaricon mr-2'>
169
+                          <i className='fa fa-fw fa-check-square-o' />
170
+                        </div>
171
+                        Enable
172
+                      </td>
173
+                    */ }
174
+                  <td>3</td>
175
+                  <td>
176
+                    <div className='adminWorkspacePage__workspaceTable__deleteworkspace primaryColorFont primaryColorFontDarkenHover'>
177
+                      <div className='adminWorkspacePage__workspaceTable__deleteworkspace__removalicon mr-3'>
178
+                        <i className='fa fa-fw fa-trash-o' />
179
+                      </div>
180
+                      Delete
181
+                    </div>
182
+                  </td>
183
+                </tr>
184
+              </tbody>
185
+            </table>
186
+          </div>
187
+
188
+        </PageContent>
189
+      </PageWrapper>
190
+    )
191
+  }
192
+}
193
+
194
+export default AdminWorkspace

+ 17 - 17
frontend_app_admin_workspace_user/src/container/AdminWorkspaceUser.jsx View File

@@ -2,15 +2,15 @@ import React from 'react'
2 2
 import { translate } from 'react-i18next'
3 3
 import i18n from '../i18n.js'
4 4
 import {
5
-  addAllResourceI18n,
6
-  // handleFetchResult,
7
-  PageWrapper,
8
-  PageTitle,
9
-  PageContent
5
+  addAllResourceI18n
6
+  // handleFetchResult
10 7
 } from 'tracim_frontend_lib'
11 8
 import { debug } from '../helper.js'
12 9
 import {
13 10
 } from '../action.async.js'
11
+import AdminWorkspace from '../component/AdminWorkspace.jsx'
12
+
13
+require('../css/index.styl')
14 14
 
15 15
 class AdminWorkspaceUser extends React.Component {
16 16
   constructor (props) {
@@ -32,7 +32,10 @@ class AdminWorkspaceUser extends React.Component {
32 32
 
33 33
   customEventReducer = ({ detail: { type, data } }) => { // action: { type: '', data: {} }
34 34
     switch (type) {
35
-      // console.log('%c<AdminWorkspaceUser> Custom event', 'color: #28a745', type, data)
35
+      case 'admin_workspace_user_showApp':
36
+        console.log('%c<AdminWorkspaceUser> Custom event', 'color: #28a745', type, data)
37
+        this.setState({config: data.config})
38
+        break
36 39
       default:
37 40
         break
38 41
     }
@@ -48,6 +51,7 @@ class AdminWorkspaceUser extends React.Component {
48 51
     const { state } = this
49 52
 
50 53
     console.log('%c<AdminWorkspaceUser> did update', `color: ${this.state.config.hexcolor}`, prevState, state)
54
+    if (prevState.config.type !== state.config.type) this.loadContent()
51 55
   }
52 56
 
53 57
   componentWillUnmount () {
@@ -67,17 +71,13 @@ class AdminWorkspaceUser extends React.Component {
67 71
 
68 72
     return (
69 73
       <div>
70
-        <PageWrapper customeClass='admin'>
71
-          <PageTitle
72
-            parentClass='admin__header'
73
-            customClass='justify-content-between'
74
-            title={'Admin'}
75
-          />
76
-
77
-          <PageContent parentClass='workspace__content'>
78
-            woot { this.state.config.type }
79
-          </PageContent>
80
-        </PageWrapper>
74
+        {this.state.config.type === 'workspace' &&
75
+          <AdminWorkspace />
76
+        }
77
+
78
+        {this.state.config.type === 'user' &&
79
+          <div>not yet implemented</div>
80
+        }
81 81
       </div>
82 82
     )
83 83
   }

+ 20 - 0
frontend_app_admin_workspace_user/src/css/index.styl View File

@@ -1 +1,21 @@
1 1
 @import "../../node_modules/tracim_frontend_lib/src/css/Variable.styl"
2
+
3
+.table th
4
+  vertical-align middle
5
+
6
+.adminWorkspacePage
7
+  &__createworkspace
8
+    &__btncreate
9
+      margin 25px 15px
10
+  &__description
11
+    margin 25px 0
12
+    font-size 20px
13
+  &__delimiter
14
+    margin 65px auto
15
+  &__workspaceTable
16
+    margin 25px 15px
17
+    &__deleteworkspace
18
+      display flex
19
+      align-items center
20
+      flex-wrap wrap
21
+      cursor pointer

+ 0 - 1
frontend_app_html-document/dist/dev View File

@@ -1 +0,0 @@
1
-../../frontend/dist/dev/

+ 0 - 1
frontend_app_html-document/dist/font View File

@@ -1 +0,0 @@
1
-../../frontend/dist/font/

+ 7 - 7
frontend_app_html-document/dist/index.html View File

@@ -6,17 +6,17 @@
6 6
   <title>Html-document App Tracim</title>
7 7
   <link rel='shortcut icon' href='favicon.ico'>
8 8
 
9
-  <link rel="stylesheet" type="text/css" href="./font/font-awesome-4.7.0/css/font-awesome.css">
9
+  <link rel="stylesheet" type="text/css" href="./asset/font/font-awesome-4.7.0/css/font-awesome.css">
10 10
   <link href="https://fonts.googleapis.com/css?family=Quicksand:300,400,500,700" rel="stylesheet">
11
-  <link rel="stylesheet" type="text/css" href="./dev/bootstrap-4.0.0-beta.css">
11
+  <link rel="stylesheet" type="text/css" href="./asset/bootstrap/bootstrap-4.0.0-beta.css">
12 12
 </head>
13 13
 <body>
14
-  <script src="./dev/jquery-3.2.1.js"></script>
15
-  <script src="./dev/popper-1.12.3.js"></script>
16
-  <script src="./dev/bootstrap-4.0.0-beta.2.js"></script>
14
+  <script src="./asset/bootstrap/jquery-3.2.1.js"></script>
15
+  <script src="./asset/bootstrap/popper-1.12.3.js"></script>
16
+  <script src="./asset/bootstrap/bootstrap-4.0.0-beta.2.js"></script>
17 17
 
18
-  <script type="text/javascript" src="/asset/tinymce/js/tinymce/jquery.tinymce.min.js"></script>
19
-  <script type="text/javascript" src="/asset/tinymce/js/tinymce/tinymce.min.js"></script>
18
+  <script type="text/javascript" src="./asset/tinymce/js/tinymce/jquery.tinymce.min.js"></script>
19
+  <script type="text/javascript" src="./asset/tinymce/js/tinymce/tinymce.min.js"></script>
20 20
 
21 21
   <div id='content'></div>
22 22
 

+ 50 - 0
frontend_app_html-document/src/action.async.js View File

@@ -77,3 +77,53 @@ export const postHtmlDocContent = (user, apiUrl, idWorkspace, idFolder, contentT
77 77
       label: newContentName
78 78
     })
79 79
   })
80
+
81
+export const putHtmlDocIsArchived = (user, apiUrl, idWorkspace, idContent) => {
82
+  return fetch(`${apiUrl}/workspaces/${idWorkspace}/contents/${idContent}/archive`, {
83
+    headers: {
84
+      'Authorization': 'Basic ' + user.auth,
85
+      ...FETCH_CONFIG.headers
86
+    },
87
+    method: 'PUT'
88
+  })
89
+}
90
+
91
+export const putHtmlDocIsDeleted = (user, apiUrl, idWorkspace, idContent) => {
92
+  return fetch(`${apiUrl}/workspaces/${idWorkspace}/contents/${idContent}/delete`, {
93
+    headers: {
94
+      'Authorization': 'Basic ' + user.auth,
95
+      ...FETCH_CONFIG.headers
96
+    },
97
+    method: 'PUT'
98
+  })
99
+}
100
+
101
+export const putHtmlDocRestoreArchived = (user, apiUrl, idWorkspace, idContent) => {
102
+  return fetch(`${apiUrl}/workspaces/${idWorkspace}/contents/${idContent}/unarchive`, {
103
+    headers: {
104
+      'Authorization': 'Basic ' + user.auth,
105
+      ...FETCH_CONFIG.headers
106
+    },
107
+    method: 'PUT'
108
+  })
109
+}
110
+
111
+export const putHtmlDocRestoreDeleted = (user, apiUrl, idWorkspace, idContent) => {
112
+  return fetch(`${apiUrl}/workspaces/${idWorkspace}/contents/${idContent}/undelete`, {
113
+    headers: {
114
+      'Authorization': 'Basic ' + user.auth,
115
+      ...FETCH_CONFIG.headers
116
+    },
117
+    method: 'PUT'
118
+  })
119
+}
120
+
121
+export const putHtmlDocRead = (user, apiUrl, idWorkspace, idContent) => {
122
+  return fetch(`${apiUrl}/users/${user.user_id}/workspaces/${idWorkspace}/contents/${idContent}/read`, {
123
+    headers: {
124
+      'Authorization': 'Basic ' + user.auth,
125
+      ...FETCH_CONFIG.headers
126
+    },
127
+    method: 'PUT'
128
+  })
129
+}

+ 28 - 0
frontend_app_html-document/src/component/HtmlDocument.jsx View File

@@ -5,6 +5,34 @@ import { MODE } from '../helper.js'
5 5
 const HtmlDocument = props => {
6 6
   return (
7 7
     <div className='wsContentHtmlDocument__contentpage__textnote html-document__contentpage__textnote'>
8
+      {props.isArchived &&
9
+        <div className='html-document__contentpage__textnote__state'>
10
+          <div className='html-document__contentpage__textnote__state__msg'>
11
+            <i className='fa fa-fw fa-archive' />
12
+            This content is archived.
13
+          </div>
14
+
15
+          <button className='html-document__contentpage__textnote__state__btnrestore btn' onClick={props.onClickRestoreArchived}>
16
+            <i className='fa fa-fw fa-archive' />
17
+            Restore
18
+          </button>
19
+        </div>
20
+      }
21
+
22
+      {props.isDeleted &&
23
+        <div className='html-document__contentpage__textnote__state'>
24
+          <div className='html-document__contentpage__textnote__state__msg'>
25
+            <i className='fa fa-fw fa-trash' />
26
+            Ce contenu est supprimé.
27
+          </div>
28
+
29
+          <button className='html-document__contentpage__textnote__state__btnrestore btn' onClick={props.onClickRestoreDeleted}>
30
+            <i className='fa fa-fw fa-trash' />
31
+            Restore
32
+          </button>
33
+        </div>
34
+      }
35
+
8 36
       {(props.mode === MODE.VIEW || props.mode === MODE.REVISION) &&
9 37
         <div>
10 38
           <div className='html-document__contentpage__textnote__version'>

+ 90 - 17
frontend_app_html-document/src/container/HtmlDocument.jsx View File

@@ -5,6 +5,7 @@ import i18n from '../i18n.js'
5 5
 import {
6 6
   addAllResourceI18n,
7 7
   handleFetchResult,
8
+  generateAvatarFromPublicName,
8 9
   PopinFixed,
9 10
   PopinFixedHeader,
10 11
   PopinFixedOption,
@@ -21,7 +22,12 @@ import {
21 22
   getHtmlDocRevision,
22 23
   postHtmlDocNewComment,
23 24
   putHtmlDocContent,
24
-  putHtmlDocStatus
25
+  putHtmlDocStatus,
26
+  putHtmlDocIsArchived,
27
+  putHtmlDocIsDeleted,
28
+  putHtmlDocRestoreArchived,
29
+  putHtmlDocRestoreDeleted,
30
+  putHtmlDocRead
25 31
 } from '../action.async.js'
26 32
 
27 33
 class HtmlDocument extends React.Component {
@@ -119,7 +125,16 @@ class HtmlDocument extends React.Component {
119 125
       handleFetchResult(await fetchResultRevision)
120 126
     ])
121 127
       .then(([resComment, resRevision]) => {
122
-        const resCommentWithProperDate = resComment.body.map(c => ({...c, created: (new Date(c.created)).toLocaleString()}))
128
+        const resCommentWithProperDateAndAvatar = resComment.body.map(c => ({
129
+          ...c,
130
+          created: (new Date(c.created)).toLocaleString(),
131
+          author: {
132
+            ...c.author,
133
+            avatar_url: c.author.avatar_url
134
+              ? c.author.avatar_url
135
+              : generateAvatarFromPublicName(c.author.public_name)
136
+          }
137
+        }))
123 138
 
124 139
         const revisionWithComment = resRevision.body
125 140
           .map((r, i) => ({
@@ -128,7 +143,7 @@ class HtmlDocument extends React.Component {
128 143
             timelineType: 'revision',
129 144
             commentList: r.comment_ids.map(ci => ({
130 145
               timelineType: 'comment',
131
-              ...resCommentWithProperDate.find(c => c.content_id === ci)
146
+              ...resCommentWithProperDateAndAvatar.find(c => c.content_id === ci)
132 147
             })),
133 148
             number: i + 1
134 149
           }))
@@ -151,6 +166,9 @@ class HtmlDocument extends React.Component {
151 166
         console.log('Error loading Timeline.', e)
152 167
         this.setState({timeline: []})
153 168
       })
169
+
170
+    await Promise.all([fetchResultHtmlDocument, fetchResultComment, fetchResultRevision])
171
+    putHtmlDocRead(loggedUser, config.apiUrl, content.workspace_id, content.content_id) // mark as read after all requests are finished
154 172
   }
155 173
 
156 174
   handleClickBtnCloseApp = () => {
@@ -251,22 +269,71 @@ class HtmlDocument extends React.Component {
251 269
   }
252 270
 
253 271
   handleClickArchive = async () => {
254
-    console.log('archive')
255
-    // const { config, content } = this.state
256
-    //
257
-    // const fetchResultArchive = await fetch(`${config.apiUrl}/workspaces/${content.workspace_id}/contents/${content.content_id}/archive`, {
258
-    //   ...FETCH_CONFIG,
259
-    //   method: 'PUT'
260
-    // })
272
+    const { loggedUser, config, content } = this.state
273
+
274
+    const fetchResultArchive = await putHtmlDocIsArchived(loggedUser, config.apiUrl, content.workspace_id, content.content_id)
275
+    switch (fetchResultArchive.status) {
276
+      case 204: this.setState(prev => ({content: {...prev.content, is_archived: true}})); break
277
+      default: GLOBAL_dispatchEvent({
278
+        type: 'addFlashMsg',
279
+        data: {
280
+          msg: this.props.t('Error while archiving document'),
281
+          type: 'warning',
282
+          delay: undefined
283
+        }
284
+      })
285
+    }
261 286
   }
262 287
 
263 288
   handleClickDelete = async () => {
264
-    console.log('delete')
265
-    // const { config, content } = this.state
266
-    // const fetchResultDelete = await fetch(`${config.apiUrl}/workspaces/${content.workspace_id}/contents/${content.content_id}/delete`, {
267
-    //   ...FETCH_CONFIG,
268
-    //   method: 'PUT'
269
-    // })
289
+    const { loggedUser, config, content } = this.state
290
+
291
+    const fetchResultArchive = await putHtmlDocIsDeleted(loggedUser, config.apiUrl, content.workspace_id, content.content_id)
292
+    switch (fetchResultArchive.status) {
293
+      case 204: this.setState(prev => ({content: {...prev.content, is_deleted: true}})); break
294
+      default: GLOBAL_dispatchEvent({
295
+        type: 'addFlashMsg',
296
+        data: {
297
+          msg: this.props.t('Error while deleting document'),
298
+          type: 'warning',
299
+          delay: undefined
300
+        }
301
+      })
302
+    }
303
+  }
304
+
305
+  handleClickRestoreArchived = async () => {
306
+    const { loggedUser, config, content } = this.state
307
+
308
+    const fetchResultRestore = await putHtmlDocRestoreArchived(loggedUser, config.apiUrl, content.workspace_id, content.content_id)
309
+    switch (fetchResultRestore.status) {
310
+      case 204: this.setState(prev => ({content: {...prev.content, is_archived: false}})); break
311
+      default: GLOBAL_dispatchEvent({
312
+        type: 'addFlashMsg',
313
+        data: {
314
+          msg: this.props.t('Error while restoring document'),
315
+          type: 'warning',
316
+          delay: undefined
317
+        }
318
+      })
319
+    }
320
+  }
321
+
322
+  handleClickRestoreDeleted = async () => {
323
+    const { loggedUser, config, content } = this.state
324
+
325
+    const fetchResultRestore = await putHtmlDocRestoreDeleted(loggedUser, config.apiUrl, content.workspace_id, content.content_id)
326
+    switch (fetchResultRestore.status) {
327
+      case 204: this.setState(prev => ({content: {...prev.content, is_deleted: false}})); break
328
+      default: GLOBAL_dispatchEvent({
329
+        type: 'addFlashMsg',
330
+        data: {
331
+          msg: this.props.t('Error while restoring document'),
332
+          type: 'warning',
333
+          delay: undefined
334
+        }
335
+      })
336
+    }
270 337
   }
271 338
 
272 339
   handleClickShowRevision = revision => {
@@ -288,7 +355,9 @@ class HtmlDocument extends React.Component {
288 355
         label: revision.label,
289 356
         raw_content: revision.raw_content,
290 357
         number: revision.number,
291
-        status: revision.status
358
+        status: revision.status,
359
+        is_archived: prev.is_archived, // archived and delete should always be taken from last version
360
+        is_deleted: prev.is_deleted
292 361
       },
293 362
       mode: MODE.REVISION
294 363
     }))
@@ -376,6 +445,10 @@ class HtmlDocument extends React.Component {
376 445
             lastVersion={timeline.filter(t => t.timelineType === 'revision').length}
377 446
             text={content.raw_content}
378 447
             onChangeText={this.handleChangeText}
448
+            isArchived={content.is_archived}
449
+            isDeleted={content.is_deleted}
450
+            onClickRestoreArchived={this.handleClickRestoreArchived}
451
+            onClickRestoreDeleted={this.handleClickRestoreDeleted}
379 452
             key={'html-document'}
380 453
           />
381 454
 

+ 14 - 3
frontend_app_html-document/src/css/index.styl View File

@@ -21,6 +21,20 @@
21 21
       height 100%
22 22
       overflow-y auto
23 23
       background-color off-white
24
+      &__state
25
+        display flex
26
+        align-items center
27
+        justify-content space-between
28
+        margin-bottom 15px
29
+        padding 5px 15px
30
+        background-color #fee498
31
+        border-radius 10px
32
+        &__msg > i
33
+          margin-right 8px
34
+        &__btnrestore
35
+          cursor pointer
36
+          & > i
37
+            margin-right 8px
24 38
       &__version
25 39
         display flex
26 40
         justify-content flex-end
@@ -37,9 +51,6 @@
37 51
     &__messagelist
38 52
       min-height 70px
39 53
       &__item
40
-        &__avatar
41
-          border 1px solid darkHtmlColor
42
-          background-color off-white
43 54
         &__content
44 55
           color darkGrey
45 56
       &__version

+ 0 - 1
frontend_app_thread/dist/dev View File

@@ -1 +0,0 @@
1
-../../frontend/dist/dev

+ 0 - 1
frontend_app_thread/dist/font View File

@@ -1 +0,0 @@
1
-../../frontend/dist/font

+ 5 - 5
frontend_app_thread/dist/index.html View File

@@ -6,14 +6,14 @@
6 6
   <title>Thread App Tracim</title>
7 7
   <link rel='shortcut icon' href='favicon.ico'>
8 8
 
9
-  <link rel="stylesheet" type="text/css" href="./font/font-awesome-4.7.0/css/font-awesome.css">
9
+  <link rel="stylesheet" type="text/css" href="./asset/font/font-awesome-4.7.0/css/font-awesome.css">
10 10
   <link href="https://fonts.googleapis.com/css?family=Quicksand:300,400,500,700" rel="stylesheet">
11
-  <link rel="stylesheet" type="text/css" href="./dev/bootstrap-4.0.0-beta.css">
11
+  <link rel="stylesheet" type="text/css" href="./asset/bootstrap/bootstrap-4.0.0-beta.css">
12 12
 </head>
13 13
 <body>
14
-<script src="./dev/jquery-3.2.1.js"></script>
15
-<script src="./dev/popper-1.12.3.js"></script>
16
-<script src="./dev/bootstrap-4.0.0-beta.2.js"></script>
14
+<script src="./asset/bootstrap/jquery-3.2.1.js"></script>
15
+<script src="./asset/bootstrap/popper-1.12.3.js"></script>
16
+<script src="./asset/bootstrap/bootstrap-4.0.0-beta.2.js"></script>
17 17
 
18 18
 <script type="text/javascript" src="/asset/tinymce/js/tinymce/jquery.tinymce.min.js"></script>
19 19
 <script type="text/javascript" src="/asset/tinymce/js/tinymce/tinymce.min.js"></script>

+ 50 - 0
frontend_app_thread/src/action.async.js View File

@@ -68,3 +68,53 @@ export const putThreadContent = (user, apiUrl, idWorkspace, idContent, label) =>
68 68
       raw_content: '' // threads have no content
69 69
     })
70 70
   })
71
+
72
+export const putThreadIsArchived = (user, apiUrl, idWorkspace, idContent) => {
73
+  return fetch(`${apiUrl}/workspaces/${idWorkspace}/contents/${idContent}/archive`, {
74
+    headers: {
75
+      'Authorization': 'Basic ' + user.auth,
76
+      ...FETCH_CONFIG.headers
77
+    },
78
+    method: 'PUT'
79
+  })
80
+}
81
+
82
+export const putThreadIsDeleted = (user, apiUrl, idWorkspace, idContent) => {
83
+  return fetch(`${apiUrl}/workspaces/${idWorkspace}/contents/${idContent}/delete`, {
84
+    headers: {
85
+      'Authorization': 'Basic ' + user.auth,
86
+      ...FETCH_CONFIG.headers
87
+    },
88
+    method: 'PUT'
89
+  })
90
+}
91
+
92
+export const putThreadRestoreArchived = (user, apiUrl, idWorkspace, idContent) => {
93
+  return fetch(`${apiUrl}/workspaces/${idWorkspace}/contents/${idContent}/unarchive`, {
94
+    headers: {
95
+      'Authorization': 'Basic ' + user.auth,
96
+      ...FETCH_CONFIG.headers
97
+    },
98
+    method: 'PUT'
99
+  })
100
+}
101
+
102
+export const putThreadRestoreDeleted = (user, apiUrl, idWorkspace, idContent) => {
103
+  return fetch(`${apiUrl}/workspaces/${idWorkspace}/contents/${idContent}/undelete`, {
104
+    headers: {
105
+      'Authorization': 'Basic ' + user.auth,
106
+      ...FETCH_CONFIG.headers
107
+    },
108
+    method: 'PUT'
109
+  })
110
+}
111
+
112
+export const putThreadRead = (user, apiUrl, idWorkspace, idContent) => {
113
+  return fetch(`${apiUrl}/users/${user.user_id}/workspaces/${idWorkspace}/contents/${idContent}/read`, {
114
+    headers: {
115
+      'Authorization': 'Basic ' + user.auth,
116
+      ...FETCH_CONFIG.headers
117
+    },
118
+    method: 'PUT'
119
+  })
120
+}

+ 2 - 2
frontend_app_thread/src/container/PopupCreateThread.jsx View File

@@ -88,9 +88,9 @@ class PopupCreateThread extends React.Component {
88 88
   })
89 89
 
90 90
   handleValidate = async () => {
91
-    const { config, appName, idWorkspace, idFolder, newContentName } = this.state
91
+    const { loggedUser, config, appName, idWorkspace, idFolder, newContentName } = this.state
92 92
 
93
-    const fetchSaveThreadDoc = postThreadContent(config.apiUrl, idWorkspace, idFolder, config.slug, newContentName)
93
+    const fetchSaveThreadDoc = postThreadContent(loggedUser, config.apiUrl, idWorkspace, idFolder, config.slug, newContentName)
94 94
 
95 95
     handleFetchResult(await fetchSaveThreadDoc)
96 96
       .then(resSave => {

+ 101 - 22
frontend_app_thread/src/container/Thread.jsx View File

@@ -4,6 +4,7 @@ import { debug } from '../helper.js'
4 4
 import {
5 5
   addAllResourceI18n,
6 6
   handleFetchResult,
7
+  generateAvatarFromPublicName,
7 8
   PopinFixed,
8 9
   PopinFixedHeader,
9 10
   PopinFixedOption,
@@ -17,7 +18,12 @@ import {
17 18
   getThreadComment,
18 19
   postThreadNewComment,
19 20
   putThreadStatus,
20
-  putThreadContent
21
+  putThreadContent,
22
+  putThreadIsArchived,
23
+  putThreadIsDeleted,
24
+  putThreadRestoreArchived,
25
+  putThreadRestoreDeleted,
26
+  putThreadRead
21 27
 } from '../action.async.js'
22 28
 
23 29
 class Thread extends React.Component {
@@ -76,7 +82,7 @@ class Thread extends React.Component {
76 82
   componentDidUpdate (prevProps, prevState) {
77 83
     const { state } = this
78 84
 
79
-    console.log('%c<Thread> did Mount', `color: ${this.state.config.hexcolor}`, prevState, state)
85
+    console.log('%c<Thread> did Update', `color: ${this.state.config.hexcolor}`, prevState, state)
80 86
 
81 87
     if (!prevState.content || !state.content) return
82 88
 
@@ -103,14 +109,24 @@ class Thread extends React.Component {
103 109
       handleFetchResult(await fetchResultThread),
104 110
       handleFetchResult(await fetchResultThreadComment)
105 111
     ])
106
-      .then(([resThread, resComment]) => this.setState({
107
-        content: resThread.body,
108
-        listMessage: resComment.body.map(c => ({
109
-          ...c,
110
-          timelineType: 'comment',
111
-          created: (new Date(c.created)).toLocaleString()
112
-        }))
113
-      }))
112
+      .then(([resThread, resComment]) => {
113
+        this.setState({
114
+          content: resThread.body,
115
+          listMessage: resComment.body.map(c => ({
116
+            ...c,
117
+            timelineType: 'comment',
118
+            created: (new Date(c.created)).toLocaleString(),
119
+            author: {
120
+              ...c.author,
121
+              avatar_url: c.author.avatar_url
122
+                ? c.author.avatar_url
123
+                : generateAvatarFromPublicName(c.author.public_name)
124
+            }
125
+          }))
126
+        })
127
+
128
+        putThreadRead(loggedUser, config.apiUrl, content.workspace_id, content.content_id)
129
+      })
114 130
       .catch(e => console.log('Error loading Thread data.', e))
115 131
   }
116 132
 
@@ -166,17 +182,81 @@ class Thread extends React.Component {
166 182
 
167 183
     handleFetchResult(await fetchResultSaveEditStatus)
168 184
       .then(resSave => {
169
-        if (resSave.status !== 204) { // 204 no content so dont take status from resSave.apiResponse.status
170
-          console.warn('Error saving thread comment. Result:', resSave, 'content:', content, 'config:', config)
171
-        } else {
185
+        if (resSave.status === 204) { // 204 no content so dont take status from resSave.apiResponse.status
172 186
           this.loadContent()
187
+        } else {
188
+          console.warn('Error saving thread comment. Result:', resSave, 'content:', content, 'config:', config)
189
+        }
190
+      })
191
+  }
192
+
193
+  handleClickArchive = async () => {
194
+    const { loggedUser, config, content } = this.state
195
+
196
+    const fetchResultArchive = await putThreadIsArchived(loggedUser, config.apiUrl, content.workspace_id, content.content_id)
197
+    switch (fetchResultArchive.status) {
198
+      case 204: this.setState(prev => ({content: {...prev.content, is_archived: true}})); break
199
+      default: GLOBAL_dispatchEvent({
200
+        type: 'addFlashMsg',
201
+        data: {
202
+          msg: this.props.t('Error while archiving thread'),
203
+          type: 'warning',
204
+          delay: undefined
205
+        }
206
+      })
207
+    }
208
+  }
209
+
210
+  handleClickDelete = async () => {
211
+    const { loggedUser, config, content } = this.state
212
+
213
+    const fetchResultArchive = await putThreadIsDeleted(loggedUser, config.apiUrl, content.workspace_id, content.content_id)
214
+    switch (fetchResultArchive.status) {
215
+      case 204: this.setState(prev => ({content: {...prev.content, is_deleted: true}})); break
216
+      default: GLOBAL_dispatchEvent({
217
+        type: 'addFlashMsg',
218
+        data: {
219
+          msg: this.props.t('Error while deleting thread'),
220
+          type: 'warning',
221
+          delay: undefined
173 222
         }
174 223
       })
224
+    }
175 225
   }
176 226
 
177
-  handleClickArchive = () => console.log('archive nyi')
227
+  handleClickRestoreArchived = async () => {
228
+    const { loggedUser, config, content } = this.state
178 229
 
179
-  handleClickDelete = () => console.log('delete nyi')
230
+    const fetchResultRestore = await putThreadRestoreArchived(loggedUser, config.apiUrl, content.workspace_id, content.content_id)
231
+    switch (fetchResultRestore.status) {
232
+      case 204: this.setState(prev => ({content: {...prev.content, is_archived: false}})); break
233
+      default: GLOBAL_dispatchEvent({
234
+        type: 'addFlashMsg',
235
+        data: {
236
+          msg: this.props.t('Error while restoring thread'),
237
+          type: 'warning',
238
+          delay: undefined
239
+        }
240
+      })
241
+    }
242
+  }
243
+
244
+  handleClickRestoreDeleted = async () => {
245
+    const { loggedUser, config, content } = this.state
246
+
247
+    const fetchResultRestore = await putThreadRestoreDeleted(loggedUser, config.apiUrl, content.workspace_id, content.content_id)
248
+    switch (fetchResultRestore.status) {
249
+      case 204: this.setState(prev => ({content: {...prev.content, is_deleted: false}})); break
250
+      default: GLOBAL_dispatchEvent({
251
+        type: 'addFlashMsg',
252
+        data: {
253
+          msg: this.props.t('Error while restoring thread'),
254
+          type: 'warning',
255
+          delay: undefined
256
+        }
257
+      })
258
+    }
259
+  }
180 260
 
181 261
   render () {
182 262
     const { config, isVisible, loggedUser, content, listMessage, newComment, timelineWysiwyg } = this.state
@@ -184,10 +264,7 @@ class Thread extends React.Component {
184 264
     if (!isVisible) return null
185 265
 
186 266
     return (
187
-      <PopinFixed
188
-        customClass={config.slug}
189
-        customColor={config.hexcolor}
190
-      >
267
+      <PopinFixed customClass={config.slug} customColor={config.hexcolor}>
191 268
         <PopinFixedHeader
192 269
           customClass={`${config.slug}__contentpage`}
193 270
           customColor={config.hexcolor}
@@ -219,9 +296,7 @@ class Thread extends React.Component {
219 296
           </div>
220 297
         </PopinFixedOption>
221 298
 
222
-        <PopinFixedContent
223
-          customClass={`${config.slug}__contentpage`}
224
-        >
299
+        <PopinFixedContent customClass={`${config.slug}__contentpage`}>
225 300
           <Timeline
226 301
             customClass={`${config.slug}__contentpage`}
227 302
             customColor={config.hexcolor}
@@ -236,6 +311,10 @@ class Thread extends React.Component {
236 311
             onClickRevisionBtn={() => {}}
237 312
             shouldScrollToBottom
238 313
             showHeader={false}
314
+            isArchived={content.is_archived}
315
+            onClickRestoreArchived={this.handleClickRestoreArchived}
316
+            isDeleted={content.is_deleted}
317
+            onClickRestoreDeleted={this.handleClickRestoreDeleted}
239 318
           />
240 319
         </PopinFixedContent>
241 320
       </PopinFixed>

+ 0 - 4
frontend_app_thread/src/css/index.styl View File

@@ -16,10 +16,6 @@
16 16
     &__contentpage
17 17
       &__content
18 18
         width 100%
19
-      &__messagelist
20
-        &__item__avatar
21
-          border 1px solid darkenThread
22
-          background-color off-white
23 19
       &__texteditor
24 20
         flex 0 0 auto
25 21
 

+ 0 - 1
frontend_app_workspace/dist/dev View File

@@ -1 +0,0 @@
1
-../../frontend/dist/dev/

+ 0 - 1
frontend_app_workspace/dist/font View File

@@ -1 +0,0 @@
1
-../../frontend/dist/font/

+ 7 - 7
frontend_app_workspace/dist/index.html View File

@@ -6,18 +6,18 @@
6 6
   <title>Workspace App Tracim</title>
7 7
   <link rel='shortcut icon' href='favicon.ico'>
8 8
 
9
-  <link rel="stylesheet" type="text/css" href="./font/font-awesome-4.7.0/css/font-awesome.css">
9
+  <link rel="stylesheet" type="text/css" href="./asset/font/font-awesome-4.7.0/css/font-awesome.css">
10 10
   <link href="https://fonts.googleapis.com/css?family=Quicksand:300,400,500,700" rel="stylesheet">
11
-  <link rel="stylesheet" type="text/css" href="./dev/bootstrap-4.0.0-beta.css">
11
+  <link rel="stylesheet" type="text/css" href="./asset/bootstrap/bootstrap-4.0.0-beta.css">
12 12
 </head>
13 13
 
14 14
 <body>
15
-  <script src="./dev/jquery-3.2.1.js"></script>
16
-  <script src="./dev/popper-1.12.3.js"></script>
17
-  <script src="./dev/bootstrap-4.0.0-beta.2.js"></script>
15
+  <script src="./asset/bootstrap/jquery-3.2.1.js"></script>
16
+  <script src="./asset/bootstrap/popper-1.12.3.js"></script>
17
+  <script src="./asset/bootstrap/bootstrap-4.0.0-beta.2.js"></script>
18 18
 
19
-  <script type="text/javascript" src="/asset/tinymce/js/tinymce/jquery.tinymce.min.js"></script>
20
-  <script type="text/javascript" src="/asset/tinymce/js/tinymce/tinymce.min.js"></script>
19
+  <script type="text/javascript" src="./asset/tinymce/js/tinymce/jquery.tinymce.min.js"></script>
20
+  <script type="text/javascript" src="./asset/tinymce/js/tinymce/tinymce.min.js"></script>
21 21
 
22 22
   <div id='content'></div>
23 23
 

+ 0 - 1
frontend_lib/dist/dev View File

@@ -1 +0,0 @@
1
-../../frontend/dist/dev

+ 0 - 1
frontend_lib/dist/font View File

@@ -1 +0,0 @@
1
-../../frontend/dist/font

+ 5 - 5
frontend_lib/dist/index.html View File

@@ -6,14 +6,14 @@
6 6
   <title>Tracim Lib</title>
7 7
   <link rel='shortcut icon' href='favicon.ico'>
8 8
 
9
-  <link rel="stylesheet" type="text/css" href="./font/font-awesome-4.7.0/css/font-awesome.css">
9
+  <link rel="stylesheet" type="text/css" href="./asset/font/font-awesome-4.7.0/css/font-awesome.css">
10 10
   <link href="https://fonts.googleapis.com/css?family=Quicksand:300,400,500,700" rel="stylesheet">
11
-  <link rel="stylesheet" type="text/css" href="./dev/bootstrap-4.0.0-beta.css">
11
+  <link rel="stylesheet" type="text/css" href="./asset/bootstrap/bootstrap-4.0.0-beta.css">
12 12
 </head>
13 13
 <body>
14
-  <script src="./dev/jquery-3.2.1.js"></script>
15
-  <script src="./dev/popper-1.12.3.js"></script>
16
-  <script src="./dev/bootstrap-4.0.0-beta.2.js"></script>
14
+  <script src="./asset/bootstrap/jquery-3.2.1.js"></script>
15
+  <script src="./asset/bootstrap/popper-1.12.3.js"></script>
16
+  <script src="./asset/bootstrap/bootstrap-4.0.0-beta.2.js"></script>
17 17
 
18 18
   <div id='content'></div>
19 19
 

+ 0 - 0
frontend_lib/package.json View File


Some files were not shown because too many files changed in this diff