Browse Source

Merge pull request #22 from lebouquetin/feature/full-history-for-files-and-pages

Tracim 9 years ago
parent
commit
5863967df9

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

@@ -18,6 +18,8 @@ from formencode import Schema
18 18
 from formencode.validators import FieldsMatch
19 19
 
20 20
 from tracim.controllers import TIMRestController
21
+from tracim.controllers.user import UserWorkspaceRestController
22
+
21 23
 from tracim.lib import CST
22 24
 from tracim.lib import helpers as h
23 25
 from tracim.lib.base import logger
@@ -212,6 +214,52 @@ class UserPasswordAdminRestController(TIMRestController):
212 214
         tg.redirect(next_url)
213 215
 
214 216
 
217
+class UserWorkspaceRestController(TIMRestController):
218
+
219
+    def _before(self, *args, **kw):
220
+        """
221
+        Instantiate the current workspace in tg.tmpl_context
222
+        :param args:
223
+        :param kw:
224
+        :return:
225
+        """
226
+        super(self.__class__, self)._before(args, kw)
227
+
228
+        api = UserApi(tg.tmpl_context.current_user)
229
+        user_id = tg.request.controller_state.routing_args.get('user_id')
230
+        user = api.get_one(user_id)
231
+        tg.tmpl_context.user_id = user_id
232
+        tg.tmpl_context.user = user
233
+
234
+    @tg.expose()
235
+    def enable_notifications(self, workspace_id, next_url=None):
236
+        workspace_id = int(workspace_id)
237
+        api = WorkspaceApi(tg.tmpl_context.current_user)
238
+
239
+        workspace = api.get_one(workspace_id)
240
+        api.enable_notifications(tg.tmpl_context.user, workspace)
241
+        tg.flash(_('User {}: notification enabled for workspace {}').format(
242
+            tg.tmpl_context.user.get_display_name(), workspace.label))
243
+
244
+        if next_url:
245
+            tg.redirect(tg.url(next_url))
246
+        tg.redirect(self.parent_controller.url(None, 'me'))
247
+
248
+    @tg.expose()
249
+    def disable_notifications(self, workspace_id, next_url=None):
250
+        workspace_id = int(workspace_id)
251
+        api = WorkspaceApi(tg.tmpl_context.current_user)
252
+
253
+        workspace = api.get_one(workspace_id)
254
+        api.disable_notifications(tg.tmpl_context.user, workspace)
255
+        tg.flash(_('User {}: notification disabled for workspace {}').format(
256
+            tg.tmpl_context.user.get_display_name(), workspace.label))
257
+
258
+        if next_url:
259
+            tg.redirect(tg.url(next_url))
260
+        tg.redirect(self.parent_controller.url(None, 'me'))
261
+
262
+
215 263
 class UserRestController(TIMRestController):
216 264
     """
217 265
      CRUD Controller allowing to manage Users
@@ -220,6 +268,7 @@ class UserRestController(TIMRestController):
220 268
 
221 269
     password = UserPasswordAdminRestController()
222 270
     profile = UserProfileAdminRestController()
271
+    workspaces = UserWorkspaceRestController()
223 272
 
224 273
     @classmethod
225 274
     def current_item_id_key_in_context(cls):
@@ -289,10 +338,14 @@ class UserRestController(TIMRestController):
289 338
 
290 339
         user = api.get_one(user_id) # FIXME
291 340
 
292
-        dictified_user = Context(CTX.USER).toDict(user, 'user')
341
+        role_api = RoleApi(tg.tmpl_context.current_user)
342
+        role_list = role_api.get_roles_for_select_field()
343
+
344
+        dictified_user = Context(CTX.ADMIN_USER).toDict(user, 'user')
293 345
         current_user_content = Context(CTX.CURRENT_USER).toDict(tmpl_context.current_user)
294
-        fake_api_content = DictLikeClass(current_user=current_user_content)
295
-        fake_api = Context(CTX.WORKSPACE).toDict(fake_api_content)
346
+        fake_api_content = DictLikeClass(current_user=current_user_content,
347
+                                         role_types=role_list)
348
+        fake_api = Context(CTX.ADMIN_USER).toDict(fake_api_content)
296 349
 
297 350
         return DictLikeClass(result = dictified_user, fake_api=fake_api)
298 351
 

+ 5 - 6
tracim/tracim/controllers/admin/workspace.py View File

@@ -54,7 +54,6 @@ class RoleInWorkspaceRestController(TIMRestController, BaseController):
54 54
     def get_one(self, user_id):
55 55
         pass
56 56
 
57
-
58 57
     def put(self, *args, **kw):
59 58
         pass
60 59
 
@@ -90,22 +89,22 @@ class RoleInWorkspaceRestController(TIMRestController, BaseController):
90 89
     def undelete(self, user_id, old_role):
91 90
         user_id = int(user_id)
92 91
         role_id = int(old_role)
93
-        self._add_user_with_role(user_id, role_id, _('User {} restored in workspace {} as {}'))
92
+        self._add_user_with_role(user_id, role_id, None, _('User {} restored in workspace {} as {}'))
94 93
         tg.redirect(self.parent_controller.url(tg.tmpl_context.workspace_id))
95 94
 
96 95
     @tg.expose()
97
-    def post(self, user_id, role_id):
96
+    def post(self, user_id, role_id, with_notif=False):
98 97
         user_id = int(user_id)
99 98
         role_id = int(role_id)
100
-        self._add_user_with_role(user_id, role_id, _('User {} added to workspace {} as {}'))
99
+        self._add_user_with_role(user_id, role_id, with_notif, _('User {} added to workspace {} as {}'))
101 100
         tg.redirect(self.parent_controller.url(tg.tmpl_context.workspace_id))
102 101
 
103
-    def _add_user_with_role(self, user_id: int, role_id: int, flash_msg_template)-> UserRoleInWorkspace:
102
+    def _add_user_with_role(self, user_id: int, role_id: int, with_notif: bool, flash_msg_template)-> UserRoleInWorkspace:
104 103
         user_api = UserApi(tg.tmpl_context.current_user)
105 104
         user = user_api.get_one(user_id)
106 105
 
107 106
         role_api = RoleApi(tg.tmpl_context.current_user)
108
-        role = role_api.create_one(user, tg.tmpl_context.workspace, role_id)
107
+        role = role_api.create_one(user, tg.tmpl_context.workspace, role_id, with_notif)
109 108
 
110 109
         tg.flash(flash_msg_template.format(
111 110
             role.user.get_display_name(),

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

@@ -79,7 +79,6 @@ class UserWorkspaceRestController(TIMRestController):
79 79
 
80 80
         if not current_id:
81 81
             # Default case is to return list of workspaces
82
-            print('ignore : ', ignored_ids)
83 82
             api = WorkspaceApi(tmpl_context.current_user)
84 83
             workspaces = api.get_all_for_user(tmpl_context.current_user,
85 84
                                               ignored_ids)

+ 2 - 2
tracim/tracim/lib/content.py View File

@@ -373,13 +373,13 @@ class ContentApi(object):
373 373
         elif new_workspace:
374 374
             item.workspace = new_workspace
375 375
 
376
-        item.revision_type = ActionDescription.EDITION
376
+        item.revision_type = ActionDescription.MOVE
377 377
 
378 378
     def move_recursively(self, item: Content,
379 379
                          new_parent: Content, new_workspace: Workspace):
380 380
         self.move(item, new_parent, False, new_workspace)
381 381
         self.save(item, do_notify=False)
382
-        print('saved item #', item.content_id, new_workspace)
382
+
383 383
         for child in item.children:
384 384
             self.move_recursively(child, item, new_workspace)
385 385
         return

+ 3 - 0
tracim/tracim/lib/helpers.py View File

@@ -177,6 +177,9 @@ def delete_label_for_item(item) -> str:
177 177
     return ContentType._DELETE_LABEL[item.type]
178 178
 
179 179
 def is_item_still_editable(item):
180
+    if item.type.id != 'comment':
181
+        return False
182
+
180 183
     # HACK - D.A - 2014-12-24 - item contains a datetime object!!!
181 184
     # 'item' is a variable which is created by serialization and it should be an instance of DictLikeClass.
182 185
     # therefore, it contains strins, integers and booleans (something json-ready or almost json-ready)

+ 3 - 1
tracim/tracim/lib/userworkspace.py View File

@@ -50,11 +50,13 @@ class RoleApi(object):
50 50
     def get_one(self, user_id, workspace_id):
51 51
         return self._get_one_rsc(user_id, workspace_id).one()
52 52
 
53
-    def create_one(self, user: User, workspace: Workspace, role_level: int, flush: bool=True) -> UserRoleInWorkspace:
53
+    def create_one(self, user: User, workspace: Workspace, role_level: int, with_notif: bool, flush: bool=True) -> UserRoleInWorkspace:
54 54
         role = self.create_role()
55 55
         role.user_id = user.user_id
56 56
         role.workspace = workspace
57 57
         role.role = role_level
58
+        if with_notif is not None:
59
+            role.do_notify = with_notif
58 60
         if flush:
59 61
             DBSession.flush()
60 62
         return role

+ 4 - 1
tracim/tracim/lib/workspace.py View File

@@ -45,7 +45,10 @@ class WorkspaceApi(object):
45 45
         workspace.description = description
46 46
 
47 47
         # By default, we force the current user to be the workspace manager
48
-        role = RoleApi(self._user).create_one(self._user, workspace, UserRoleInWorkspace.WORKSPACE_MANAGER)
48
+        # And to receive email notifications
49
+        role = RoleApi(self._user).create_one(self._user, workspace,
50
+                                              UserRoleInWorkspace.WORKSPACE_MANAGER,
51
+                                              with_notif=True)
49 52
 
50 53
         DBSession.add(workspace)
51 54
         DBSession.add(role)

+ 0 - 1
tracim/tracim/model/auth.py View File

@@ -213,7 +213,6 @@ class User(DeclarativeBase):
213 213
 
214 214
     def get_role(self, workspace: 'Workspace') -> int:
215 215
         for role in self.roles:
216
-            print('IS EQUALS ? ', role.workspace, workspace)
217 216
             if role.workspace == workspace:
218 217
                 return role.role
219 218
 

+ 99 - 17
tracim/tracim/model/data.py View File

@@ -24,7 +24,7 @@ from sqlalchemy.types import LargeBinary
24 24
 from sqlalchemy.types import Text
25 25
 from sqlalchemy.types import Unicode
26 26
 
27
-from tg.i18n import lazy_ugettext as l_
27
+from tg.i18n import lazy_ugettext as l_, ugettext as _
28 28
 
29 29
 from tracim.model import DeclarativeBase
30 30
 from tracim.model.auth import User
@@ -169,29 +169,32 @@ class ActionDescription(object):
169 169
     STATUS_UPDATE = 'status-update'
170 170
     UNARCHIVING = 'unarchiving'
171 171
     UNDELETION = 'undeletion'
172
+    MOVE = 'move'
172 173
 
173 174
     _ICONS = {
174 175
         'archiving': 'fa fa-archive',
175 176
         'content-comment': 'fa-comment-o',
176 177
         'creation': 'fa-magic',
177
-        'deletion': 'fa fa-trash',
178
-        'edition': 'fa fa-edit',
178
+        'deletion': 'fa-trash',
179
+        'edition': 'fa-edit',
179 180
         'revision': 'fa-history',
180 181
         'status-update': 'fa-random',
181
-        'unarchiving': 'fa fa-file-archive-o',
182
-        'undeletion': 'fa-trash-o'
182
+        'unarchiving': 'fa-file-archive-o',
183
+        'undeletion': 'fa-trash-o',
184
+        'move': 'fa-arrows'
183 185
     }
184 186
 
185 187
     _LABELS = {
186 188
         'archiving': l_('archive'),
187
-        'content-comment': l_('commente'),
188
-        'creation': l_('creation'),
189
-        'deletion': l_('deletion'),
190
-        'edition': l_('modified'),
191
-        'revision': l_('revision'),
192
-        'status-update': l_('statut'),
193
-        'unarchiving': l_('un-archived'),
194
-        'undeletion': l_('un-deleted'),
189
+        'content-comment': l_('Item commented'),
190
+        'creation': l_('Item created'),
191
+        'deletion': l_('Item deleted'),
192
+        'edition': l_('item modified'),
193
+        'revision': l_('New revision'),
194
+        'status-update': l_('New status'),
195
+        'unarchiving': l_('Item unarchived'),
196
+        'undeletion': l_('Item undeleted'),
197
+        'move': l_('Item moved')
195 198
     }
196 199
 
197 200
     def __init__(self, id):
@@ -199,6 +202,7 @@ class ActionDescription(object):
199 202
         self.id = id
200 203
         self.label = ActionDescription._LABELS[id]
201 204
         self.icon = ActionDescription._ICONS[id]
205
+        self.css = ''
202 206
 
203 207
     @classmethod
204 208
     def allowed_values(cls):
@@ -210,7 +214,8 @@ class ActionDescription(object):
210 214
                 cls.REVISION,
211 215
                 cls.STATUS_UPDATE,
212 216
                 cls.UNARCHIVING,
213
-                cls.UNDELETION]
217
+                cls.UNDELETION,
218
+                cls.MOVE]
214 219
 
215 220
 
216 221
 class ContentStatus(object):
@@ -406,10 +411,15 @@ class ContentType(object):
406 411
     def sorted(cls, types: ['ContentType']) -> ['ContentType']:
407 412
         return sorted(types, key=lambda content_type: content_type.priority)
408 413
 
414
+    @property
415
+    def type(self):
416
+        return self.id
417
+
409 418
     def __init__(self, type):
410
-        self.type = type
419
+        self.id = type
411 420
         self.icon = ContentType._CSS_ICONS[type]
412
-        self.color = ContentType._CSS_COLORS[type]
421
+        self.color = ContentType._CSS_COLORS[type]  # deprecated
422
+        self.css = ContentType._CSS_COLORS[type]
413 423
         self.label = ContentType._LABEL[type]
414 424
         self.priority = ContentType._ORDER_WEIGHT[type]
415 425
 
@@ -594,10 +604,21 @@ class Content(DeclarativeBase):
594 604
         except Exception as e:
595 605
             print(e.__str__())
596 606
             print('----- /*\ *****')
597
-            raise ValueError('No allowed content property')
607
+            raise ValueError('Not allowed content property')
598 608
 
599 609
         return ContentType.sorted(types)
600 610
 
611
+    def get_history(self) -> '[VirtualEvent]':
612
+        events = []
613
+        for comment in self.get_comments():
614
+            events.append(VirtualEvent.create_from_content(comment))
615
+        for revision in self.revisions:
616
+            events.append(VirtualEvent.create_from_content_revision(revision))
617
+
618
+        sorted_events = sorted(events,
619
+                               key=lambda event: event.created, reverse=True)
620
+        return sorted_events
621
+
601 622
 
602 623
 class ContentChecker(object):
603 624
 
@@ -649,6 +670,7 @@ class ContentRevisionRO(DeclarativeBase):
649 670
     file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
650 671
     file_content = deferred(Column(LargeBinary(), unique=False, nullable=False, default=None))
651 672
 
673
+    type = Column(Unicode(32), unique=False, nullable=False)
652 674
     status = Column(Unicode(32), unique=False, nullable=False)
653 675
     created = Column(DateTime, unique=False, nullable=False)
654 676
     updated = Column(DateTime, unique=False, nullable=False)
@@ -665,6 +687,9 @@ class ContentRevisionRO(DeclarativeBase):
665 687
     def get_status(self):
666 688
         return ContentStatus(self.status)
667 689
 
690
+    def get_label(self):
691
+        return self.label if self.label else self.file_name if self.file_name else ''
692
+
668 693
     def get_last_action(self) -> ActionDescription:
669 694
         return ActionDescription(self.revision_type)
670 695
 
@@ -678,3 +703,60 @@ class NodeTreeItem(object):
678 703
         self.node = node
679 704
         self.children = children
680 705
         self.is_selected = is_selected
706
+
707
+class VirtualEvent(object):
708
+    @classmethod
709
+    def create_from(cls, object):
710
+        if Content == object.__class__:
711
+            return cls.create_from_content(object)
712
+        elif ContentRevisionRO == object.__class__:
713
+            return cls.create_from_content_revision(object)
714
+
715
+    @classmethod
716
+    def create_from_content(cls, content: Content):
717
+        content_type = ContentType(content.type)
718
+
719
+        label = content.get_label()
720
+        if content.type==ContentType.Comment:
721
+            label = _('<strong>{}</strong> wrote:').format(content.owner.get_display_name())
722
+
723
+        return VirtualEvent(id=content.content_id,
724
+                            created=content.created,
725
+                            owner=content.owner,
726
+                            type=content_type,
727
+                            label=label,
728
+                            content=content.description,
729
+                            ref_object=content)
730
+
731
+    @classmethod
732
+    def create_from_content_revision(cls, revision: ContentRevisionRO):
733
+        action_description = ActionDescription(revision.revision_type)
734
+
735
+        return VirtualEvent(id=revision.revision_id,
736
+                            created=revision.created,
737
+                            owner=revision.owner,
738
+                            type=action_description,
739
+                            label=action_description.label,
740
+                            content='',
741
+                            ref_object=revision)
742
+
743
+    def __init__(self, id, created, owner, type, label, content, ref_object):
744
+        self.id = id
745
+        self.created = created
746
+        self.owner = owner
747
+        self.type = type
748
+        self.label = label
749
+        self.content = content
750
+        self.ref_object = ref_object
751
+
752
+        print(type)
753
+        assert hasattr(type, 'id')
754
+        assert hasattr(type, 'css')
755
+        assert hasattr(type, 'icon')
756
+        assert hasattr(type, 'label')
757
+
758
+    def created_as_delta(self, delta_from_datetime:datetime=None):
759
+        if not delta_from_datetime:
760
+            delta_from_datetime = datetime.now()
761
+        return format_timedelta(delta_from_datetime - self.created,
762
+                                locale=tg.i18n.get_lang()[0])

+ 53 - 20
tracim/tracim/model/serializers.py View File

@@ -22,6 +22,7 @@ from tracim.model.data import Content
22 22
 from tracim.model.data import ContentType
23 23
 from tracim.model.data import RoleType
24 24
 from tracim.model.data import UserRoleInWorkspace
25
+from tracim.model.data import VirtualEvent
25 26
 from tracim.model.data import Workspace
26 27
 
27 28
 from tracim.model import data as pmd
@@ -61,9 +62,11 @@ class ContextConverterNotFoundException(Exception):
61 62
 
62 63
 class CTX(object):
63 64
     """ constants that are used for serialization / dictification of models"""
65
+    ADMIN_USER = 'ADMIN_USER'
64 66
     ADMIN_WORKSPACE = 'ADMIN_WORKSPACE'
65 67
     ADMIN_WORKSPACES = 'ADMIN_WORKSPACES'
66 68
     CONTENT_LIST = 'CONTENT_LIST'
69
+    CONTENT_HISTORY = 'CONTENT_HISTORY'
67 70
     CURRENT_USER = 'CURRENT_USER'
68 71
     DEFAULT = 'DEFAULT' # default context. This will allow to define a serialization method to be used by default
69 72
     EMAIL_NOTIFICATION = 'EMAIL_NOTIFICATION'
@@ -351,8 +354,6 @@ def serialize_node_for_page(content: Content, context: Context):
351 354
     if content.type in (ContentType.Page, ContentType.File) :
352 355
         data_container = content
353 356
 
354
-
355
-
356 357
         # The following properties are overriden by revision values
357 358
         if content.revision_to_serialize>0:
358 359
             for revision in content.revisions:
@@ -361,20 +362,21 @@ def serialize_node_for_page(content: Content, context: Context):
361 362
                     break
362 363
 
363 364
         result = DictLikeClass(
364
-            id = content.content_id,
365
-            parent = context.toDict(content.parent),
366
-            workspace = context.toDict(content.workspace),
367
-            type = content.type,
368
-
369
-            content = data_container.description,
370
-            created = data_container.created,
371
-            label = data_container.label,
372
-            icon = ContentType.get_icon(content.type),
373
-            owner = context.toDict(data_container.owner),
374
-            status = context.toDict(data_container.get_status()),
375
-            links = context.toDict(content.extract_links_from_content(data_container.description)),
376
-            revisions = context.toDict(sorted(content.revisions, key=lambda v: v.created, reverse=True)),
377
-            selected_revision = 'latest' if content.revision_to_serialize<=0 else content.revision_to_serialize
365
+            id=content.content_id,
366
+            parent=context.toDict(content.parent),
367
+            workspace=context.toDict(content.workspace),
368
+            type=content.type,
369
+
370
+            content=data_container.description,
371
+            created=data_container.created,
372
+            label=data_container.label,
373
+            icon=ContentType.get_icon(content.type),
374
+            owner=context.toDict(data_container.owner),
375
+            status=context.toDict(data_container.get_status()),
376
+            links=context.toDict(content.extract_links_from_content(data_container.description)),
377
+            revisions=context.toDict(sorted(content.revisions, key=lambda v: v.created, reverse=True)),
378
+            selected_revision='latest' if content.revision_to_serialize<=0 else content.revision_to_serialize,
379
+            history=Context(CTX.CONTENT_HISTORY).toDict(content.get_history())
378 380
         )
379 381
 
380 382
         if content.type==ContentType.File:
@@ -395,6 +397,31 @@ def serialize_node_for_page(content: Content, context: Context):
395 397
     raise NotImplementedError
396 398
 
397 399
 
400
+@pod_serializer(VirtualEvent, CTX.CONTENT_HISTORY)
401
+def serialize_content_for_history(event: VirtualEvent, context: Context):
402
+    urls = DictLikeClass({'delete': None})
403
+    if ContentType.Comment == event.type.id:
404
+        urls = context.toDict({
405
+          'delete': context.url('/workspaces/{wid}/folders/{fid}/{ctype}/{cid}/comments/{commentid}/put_delete'.format(
406
+              wid = event.ref_object.workspace_id,
407
+              fid=event.ref_object.parent.parent_id,
408
+              ctype=event.ref_object.parent.type+'s',
409
+              cid=event.ref_object.parent.content_id,
410
+              commentid=event.ref_object.content_id))
411
+        })
412
+
413
+    return DictLikeClass(
414
+        owner=context.toDict(event.owner),
415
+        id=event.id,
416
+        label=event.label,
417
+        type=context.toDict(event.type),
418
+        created=event.created,
419
+        created_as_delta=event.created_as_delta(),
420
+        content=event.content,
421
+        urls = urls
422
+    )
423
+
424
+
398 425
 @pod_serializer(Content, CTX.THREAD)
399 426
 def serialize_node_for_page(item: Content, context: Context):
400 427
     if item.type==ContentType.Thread:
@@ -411,7 +438,8 @@ def serialize_node_for_page(item: Content, context: Context):
411 438
             status = context.toDict(item.get_status()),
412 439
             type = item.type,
413 440
             workspace = context.toDict(item.workspace),
414
-            comments = reversed(context.toDict(item.get_comments()))
441
+            comments = reversed(context.toDict(item.get_comments())),
442
+            history = Context(CTX.CONTENT_HISTORY).toDict(item.get_history())
415 443
         )
416 444
 
417 445
     if item.type==ContentType.Comment:
@@ -660,6 +688,7 @@ def serialize_content_for_folder_content_list(content: Content, context: Context
660 688
 def serialize_breadcrumb_item(content_type: ContentType, context: Context):
661 689
     return DictLikeClass(content_type.toDict())
662 690
 
691
+
663 692
 @pod_serializer(Content, CTX.SEARCH)
664 693
 def serialize_content_for_search_result(content: Content, context: Context):
665 694
 
@@ -757,6 +786,7 @@ def serialize_user_list_default(profile: Profile, context: Context):
757 786
 
758 787
 
759 788
 @pod_serializer(RoleType, CTX.ADMIN_WORKSPACE)
789
+@pod_serializer(RoleType, CTX.ADMIN_USER)
760 790
 def serialize_role_list_for_select_field_in_workspace(role_type: RoleType, context: Context):
761 791
     """
762 792
     Actually, roles are serialized as users (with minimal information)
@@ -810,6 +840,7 @@ def serialize_user_list_default(user: User, context: Context):
810 840
 
811 841
 
812 842
 @pod_serializer(User, CTX.USER)
843
+@pod_serializer(User, CTX.ADMIN_USER)
813 844
 @pod_serializer(User, CTX.CURRENT_USER)
814 845
 def serialize_user_for_user(user: User, context: Context):
815 846
     """
@@ -848,13 +879,14 @@ def serialize_role_in_workspace(role: UserRoleInWorkspace, context: Context):
848 879
     result['style'] = role.style
849 880
     result['role_description'] = role.role_as_label()
850 881
     result['email'] = role.user.email
851
-    result['user'] = role.user
882
+    result['user'] = context.toDict(role.user)
852 883
     result['notifications_subscribed'] = role.do_notify
853 884
     return result
854 885
 
855 886
 
856 887
 @pod_serializer(UserRoleInWorkspace, CTX.USER)
857 888
 @pod_serializer(UserRoleInWorkspace, CTX.CURRENT_USER)
889
+@pod_serializer(UserRoleInWorkspace, CTX.ADMIN_USER)
858 890
 def serialize_role_in_list_for_user(role: UserRoleInWorkspace, context: Context):
859 891
     """
860 892
     Actually, roles are serialized as users (with minimal information)
@@ -868,7 +900,7 @@ def serialize_role_in_list_for_user(role: UserRoleInWorkspace, context: Context)
868 900
     result['label'] = role.role_as_label()
869 901
     result['style'] = RoleType(role.role).css_style
870 902
     result['workspace'] =  context.toDict(role.workspace)
871
-    result['user'] = role.user
903
+    result['user'] = Context(CTX.DEFAULT).toDict(role.user)
872 904
     result['notifications_subscribed'] = role.do_notify
873 905
 
874 906
     # result['workspace_name'] = role.workspace.label
@@ -882,7 +914,8 @@ def serialize_role_in_list_for_user(role: UserRoleInWorkspace, context: Context)
882 914
 def serialize_workspace_default(workspace: Workspace, context: Context):
883 915
     result = DictLikeClass(
884 916
         id = workspace.workspace_id,
885
-        label = workspace.label,
917
+        label = workspace.label,  # FIXME - 2015-08-20 - remove this property
918
+        name = workspace.label,  # use name instead of label
886 919
         url = context.url('/workspaces/{}'.format(workspace.workspace_id))
887 920
     )
888 921
     return result

+ 5 - 1
tracim/tracim/public/assets/css/dashboard.css View File

@@ -349,4 +349,8 @@ h3 { background-color: #f5f5f5;}
349 349
     padding: 0;
350 350
     position: absolute;
351 351
     top: 0;
352
-}
352
+}
353
+
354
+#t-full-app-alert-message-id > div.alert {
355
+    box-shadow: 0px 0px 5px 5px rgba(0, 0, 0, 0.3);
356
+}

+ 6 - 2
tracim/tracim/templates/admin/user_getone.mak View File

@@ -61,7 +61,7 @@
61 61
                             ${ICON.FA('fa-bar-chart t-less-visible')}
62 62
                             ${_('Global profile')}
63 63
                         </h3>
64
-                        ${P.USER_PROFILE(result.user)}
64
+                        ${P.USER_PROFILE(fake_api.current_user, result.user)}
65 65
                     </div>
66 66
                     <div style="margin-top: 4em;">
67 67
                         <h3>
@@ -81,7 +81,11 @@
81 81
                                     </tr>
82 82
                                 </thead>
83 83
                                 % for role in result.user.roles:
84
-                                    ${TABLE_ROW.USER_ROLE_IN_WORKSPACE(role)}
84
+<%
85
+    enable_link = '/admin/users/{user}/workspaces/{workspace}/enable_notifications?next_url=/admin/users/{user}'
86
+    disable_link = '/admin/users/{user}/workspaces/{workspace}/disable_notifications?next_url=/admin/users/{user}'
87
+%>
88
+                                    ${TABLE_ROW.USER_ROLE_IN_WORKSPACE(fake_api.current_user, role, show_id=True, enable_link=enable_link, disable_link=disable_link)}
85 89
                                 % endfor
86 90
                             </table>
87 91
                         % endif

+ 6 - 0
tracim/tracim/templates/admin/workspace_getone.mak View File

@@ -83,6 +83,12 @@
83 83
                                             % endfor
84 84
                                         </div>
85 85
 
86
+                                        <div class="checkbox">
87
+                                            <label>
88
+                                                <input type="checkbox" id="with_notif" name="with_notif" checked="checked"/> ${_('Subscribe to mail notifications')}
89
+                                            </label>
90
+                                        </div>
91
+
86 92
                                         <span class="pull-right" style="margin-top: 0.5em;">
87 93
                                             <button id="current-document-add-comment-save-button" type="submit" class="btn btn-small btn-success" title="Add first comment"><i class=" fa fa-check"></i> ${_('Validate')}</button>
88 94
                                         </span>

+ 5 - 21
tracim/tracim/templates/file/getone.mak View File

@@ -154,31 +154,15 @@
154 154
     <div class="col-sm-7 col-sm-offset-3">
155 155
         <div class="t-spacer-above">
156 156
             <span id="associated-revisions" ></span>
157
-            <h4 class="anchored-title">${_('File revisions')}</h4>
157
+            <h4 class="anchored-title">${_('File history')}</h4>
158 158
             <div>
159 159
                 <table class="table table-striped table-hover">
160
-                    % for revid, revision in reversed(list(enumerate(reversed(result.file.revisions)))):
161
-                        <% warning_or_not = ('', 'warning')[result.file.selected_revision==revision.id] %>
162
-                        <tr class="${warning_or_not}">
163
-## FIXME - 2015-07-22 - D.A. - Do we really need to show a rev. id ?!
164
-## <td><span class="label label-default">v${revid}</span></td>
165
-                            <td class="t-less-visible">
166
-                                <span class="label label-default">${ICON.FA_FW(revision.action.icon)}
167
-                                ${revision.action.label}</span>
168
-                            </td>
169
-                            <td>${h.date(revision.created)}</td>
170
-                            <td>${h.time(revision.created)}</td>
171
-                            <td>${revision.owner.name}</td>
172
-                            <td><a href="${tg.url('/workspaces/{}/folders/{}/files/{}?revision_id={}').format(result.file.workspace.id, result.file.parent.id, result.file.id, revision.id)}">${revision.label}</a></td>
173
-                            <td class="t-less-visible" title="${_('Currently shown')}">
174
-                                % if warning_or_not:
175
-                                    ${ICON.FA_FW('fa fa-caret-left')} ${_('shown').format(result.file.selected_revision)}
176
-                                % endif
177
-                            </td>
178
-                        </tr>
160
+                    % for event in result.file.history:
161
+                        ${WIDGETS.SECURED_HISTORY_VIRTUAL_EVENT_AS_TABLE_ROW(fake_api.current_user, event, result.file.selected_revision)}
179 162
                     % endfor
180 163
                 </table>
181 164
             </div>
182 165
         </div>
183 166
     </div>
184
-<div/>
167
+<div/>
168
+

+ 1 - 1
tracim/tracim/templates/home.mak View File

@@ -157,7 +157,7 @@
157 157
                                                 </tr>
158 158
                                             </thead>
159 159
                                             % for role in fake_api.current_user.roles:
160
-                                                ${TABLE_ROW.USER_ROLE_IN_WORKSPACE(role, show_id=False, enable_link='/user/me/workspaces/{workspace}/enable_notifications?next_url=/home', disable_link='/user/me/workspaces/{workspace}/disable_notifications?next_url=/home')}
160
+                                                ${TABLE_ROW.USER_ROLE_IN_WORKSPACE(fake_api.current_user, role, show_id=False, enable_link='/user/me/workspaces/{workspace}/enable_notifications?next_url=/home', disable_link='/user/me/workspaces/{workspace}/disable_notifications?next_url=/home')}
161 161
                                             % endfor
162 162
                                         </table>
163 163
                                     % endif

+ 4 - 20
tracim/tracim/templates/page/getone.mak View File

@@ -98,31 +98,15 @@
98 98
     <div class="col-sm-7 col-sm-offset-3">
99 99
         <div class="t-spacer-above">
100 100
             <span id="associated-revisions" ></span>
101
-            <h4 class="anchored-title">${_('Page revisions')}</h4>
101
+            <h4 class="anchored-title">${_('Page history')}</h4>
102 102
             <div>
103 103
                 <table class="table table-striped table-hover">
104
-                    % for revid, revision in reversed(list(enumerate(reversed(result.page.revisions)))):
105
-                        <% warning_or_not = ('', 'warning')[result.page.selected_revision==revision.id] %>
106
-                        <tr class="${warning_or_not}">
107
-## FIXME - 2015-07-22 - D.A. - Do we really need to show a rev. id ?!
108
-## <td><span class="label label-default">v${revid}</span></td>
109
-                            <td class="t-less-visible">
110
-                                <span class="label label-default">${ICON.FA_FW(revision.action.icon)}
111
-                                ${revision.action.label}</span>
112
-                            </td>
113
-                            <td>${h.date(revision.created)}</td>
114
-                            <td>${h.time(revision.created)}</td>
115
-                            <td>${revision.owner.name}</td>
116
-                            <td><a href="${tg.url('/workspaces/{}/folders/{}/pages/{}?revision_id={}').format(result.page.workspace.id, result.page.parent.id, result.page.id, revision.id)}">${revision.label}</a></td>
117
-                            <td class="t-less-visible" title="${_('Currently shown')}">
118
-                                % if warning_or_not:
119
-                                    ${ICON.FA_FW('fa fa-caret-left')} ${_('shown').format(result.page.selected_revision)}
120
-                                % endif
121
-                            </td>
122
-                        </tr>
104
+                    % for event in result.page.history:
105
+                        ${WIDGETS.SECURED_HISTORY_VIRTUAL_EVENT_AS_TABLE_ROW(fake_api.current_user, event, result.page.selected_revision)}
123 106
                     % endfor
124 107
                 </table>
125 108
             </div>
126 109
         </div>
127 110
     </div>
128 111
 <div/>
112
+

+ 10 - 22
tracim/tracim/templates/thread/getone.mak View File

@@ -100,27 +100,15 @@
100 100
     </div>
101 101
 </div>
102 102
 
103
-% for comment in result.thread.comments:
104
-    ${WIDGETS.SECURED_TIMELINE_ITEM(fake_api.current_user, comment)}
103
+% for event in result.thread.history:
104
+    ## TODO - D.A. - 2015-08-20
105
+    ## Allow to show full history (with status change and archive/unarchive)
106
+    % if event.type.id in ('comment', 'creation'):
107
+        ${WIDGETS.SECURED_HISTORY_VIRTUAL_EVENT(fake_api.current_user, event)}
108
+    % endif
105 109
 % endfor
106 110
 
107
-## <hr class="tracim-panel-separator"/>
108
-## <div>
109
-##     <h4 id="associated-links" class="anchored-title" >${_('Links extracted from the thread')}</h4>
110
-##     <div>
111
-##         % if len(result.thread.links)<=0:
112
-##             <p class="pod-empty">${_('No link found.')}</p>
113
-##         % else:
114
-##             <ul>
115
-##                 % for link in result.thread.links:
116
-##                     <li><a href="${link.href}">${link.label if link.label else link.href}</a></li>
117
-##                 % endfor
118
-##             </ul>
119
-##         % endif
120
-##     </div>
121
-##     <hr/>
122
-## 
123
-##     % for comment in result.thread.comments:
124
-##         ${comment}
125
-##     % endfor
126
-## </div>
111
+## % for comment in result.thread.comments:
112
+##     ${WIDGETS.SECURED_TIMELINE_ITEM(fake_api.current_user, comment)}
113
+## % endfor
114
+##

+ 1 - 1
tracim/tracim/templates/user_get_me.mak View File

@@ -62,7 +62,7 @@
62 62
                             </table>
63 63
                         % endif
64 64
                     </div>
65
-                    % if len(result.user.roles)>0:
65
+                    % if len(result.user.roles) > 0:
66 66
                         <p class="alert alert-info">${_('You can configure your email notifications by clicking on the email icons above')}</p>
67 67
                     % endif
68 68
                 </div>

+ 76 - 10
tracim/tracim/templates/user_workspace_widgets.mak View File

@@ -303,34 +303,100 @@
303 303
 </%def>
304 304
 
305 305
 <%def name="SECURED_TIMELINE_ITEM(user, item)">
306
+##     <div class="row t-odd-or-even t-hacky-thread-comment-border-top">
307
+##         <div class="col-sm-7 col-sm-offset-3">
308
+##             <div class="t-timeline-item">
309
+## ##                <i class="fa fa-fw fa-3x fa-comment-o t-less-visible" style="margin-left: -1.5em; float:left;"></i>
310
+##                 ${ICON.FA_FW('fa fa-3x fa-comment-o t-less-visible t-timeline-item-icon')}
311
+##
312
+##                 <h5 style="margin: 0;">
313
+##                     <span class="tracim-less-visible">${_('<strong>{}</strong> wrote:').format(item.owner.name)|n}</span>
314
+##
315
+##                     <div class="pull-right text-right t-timeline-item-moment" title="${h.date_time(item.created)|n}">
316
+##                         ${_('{delta} ago').format(delta=item.created_as_delta)}
317
+##
318
+##                         % if h.is_item_still_editable(item) and item.owner.id==user.id:
319
+##                             <br/>
320
+## ##                            <div class="btn-group">
321
+##                                 <a class="t-timeline-comment-delete-button" href="${item.urls.delete}">
322
+##                                     ${_('delete')} ${ICON.FA('fa fa-trash-o')}
323
+## ##                                    ${TIM.ICO_TOOLTIP(16, 'status/user-trash-full', h.delete_label_for_item(item))}
324
+##                                 </a>
325
+## ##                            </div>
326
+##                         % endif
327
+##                     </div>
328
+##                 </h5>
329
+##                 <div class="t-timeline-item-content">
330
+##                     <div>${item.content|n}</div>
331
+##                     <br/>
332
+##                 </div>
333
+##             </div>
334
+##         </div>
335
+##     </div>
336
+</%def>
337
+
338
+<%def name="SECURED_HISTORY_VIRTUAL_EVENT(user, event)">
306 339
     <div class="row t-odd-or-even t-hacky-thread-comment-border-top">
307 340
         <div class="col-sm-7 col-sm-offset-3">
308 341
             <div class="t-timeline-item">
309 342
 ##                <i class="fa fa-fw fa-3x fa-comment-o t-less-visible" style="margin-left: -1.5em; float:left;"></i>
310
-                ${ICON.FA_FW('fa fa-3x fa-comment-o t-less-visible t-timeline-item-icon')}
343
+
344
+                ${ICON.FA_FW('fa fa-3x t-less-visible t-timeline-item-icon '+event.type.icon)}
311 345
 
312 346
                 <h5 style="margin: 0;">
313
-                    <span class="tracim-less-visible">${_('<strong>{}</strong> wrote:').format(item.owner.name)|n}</span>
314 347
 
315
-                    <div class="pull-right text-right t-timeline-item-moment" title="${h.date_time(item.created)|n}">
316
-                        ${_('{delta} ago').format(delta=item.created_as_delta)}
348
+                    % if 'comment' == event.type.id:
349
+                        <span class="tracim-less-visible">${_('<strong>{}</strong> wrote:').format(event.owner.name)|n}</span>
350
+                    %else:
351
+                        <span class="tracim-less-visible">${_('{} by <strong>{}</strong>').format(event.label, event.owner.name)|n}</span>
352
+                    % endif
353
+
354
+                    <div class="pull-right text-right t-timeline-item-moment" title="${h.date_time(event.created)|n}">
355
+                        ${_('{delta} ago').format(delta=event.created_as_delta)}
317 356
 
318
-                        % if h.is_item_still_editable(item) and item.owner.id==user.id:
357
+                        % if h.is_item_still_editable(event) and event.owner.id==user.id:
319 358
                             <br/>
320
-##                            <div class="btn-group">
321
-                                <a class="t-timeline-comment-delete-button" href="${item.urls.delete}">
359
+                                <a class="t-timeline-comment-delete-button" href="${event.urls.delete}">
322 360
                                     ${_('delete')} ${ICON.FA('fa fa-trash-o')}
323
-##                                    ${TIM.ICO_TOOLTIP(16, 'status/user-trash-full', h.delete_label_for_item(item))}
324 361
                                 </a>
325
-##                            </div>
326 362
                         % endif
327 363
                     </div>
328 364
                 </h5>
329 365
                 <div class="t-timeline-item-content">
330
-                    <div>${item.content|n}</div>
366
+                    <div>${event.content|n}</div>
331 367
                     <br/>
332 368
                 </div>
333 369
             </div>
334 370
         </div>
335 371
     </div>
336 372
 </%def>
373
+
374
+<%def name="SECURED_HISTORY_VIRTUAL_EVENT_AS_TABLE_ROW(user, event, current_revision_id)">
375
+    <% warning_or_not = ('', 'warning')[current_revision_id==event.id] %>
376
+    <tr class="${warning_or_not}">
377
+        <td class="t-less-visible">
378
+            <span class="label label-default">${ICON.FA_FW(event.type.icon)} ${event.type.label}</span>
379
+        </td>
380
+        <td title="${h.date_time(event.created)|n}">${_('{delta} ago').format(delta=event.created_as_delta)}</td>
381
+        <td>${event.owner.name}</td>
382
+## FIXME - REMOVE                            <td>${event}</td>
383
+
384
+        % if 'comment' == event.type.id:
385
+            <td colspan="2">
386
+                ${event.content|n}
387
+            </td>
388
+        % else:
389
+
390
+            <td>
391
+                % if event.type.id in ('creation', 'edition', 'revision'):
392
+                    <a href="${'?revision_id={}'.format(event.id)}">${_('View revision')}</a>
393
+                % endif
394
+            </td>
395
+            <td class="t-less-visible" title="${_('Currently shown')}">
396
+                % if warning_or_not:
397
+                    ${ICON.FA_FW('fa fa-caret-left')}&nbsp;${_('shown')}
398
+                % endif
399
+            </td>
400
+        % endif
401
+    </tr>
402
+</%def>

+ 1 - 1
tracim/tracim/templates/widgets/paragraph.mak View File

@@ -1,7 +1,7 @@
1 1
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
2 2
 <%namespace name="BUTTON" file="tracim.templates.widgets.button"/>
3 3
 
4
-<%def name="USER_PROFILE(user)">
4
+<%def name="USER_PROFILE(current_user, user)">
5 5
     % if user.profile.id >= 1:
6 6
         <p>${ICON.FA('fa-male t-green fa-lg fa-fw')}<span> ${_('This user a standard user.')}</span></p>
7 7
     %else:

+ 30 - 4
tracim/tracim/templates/widgets/table_row.mak View File

@@ -2,14 +2,27 @@
2 2
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
3 3
 <%namespace name="SPAN" file="tracim.templates.widgets.span"/>
4 4
 
5
-<%def name="USER_ROLE_IN_WORKSPACE(role, show_id=True, enable_link=None, disable_link=None)">
5
+<%def name="USER_ROLE_IN_WORKSPACE(current_user, role, show_id=True, enable_link=None, disable_link=None, role_types=None)">
6 6
     <tr>
7 7
         % if show_id:
8 8
             <td class="text-right">${role.workspace.id}</td>
9 9
         % endif
10 10
         <td><a href="${tg.url('/admin/workspaces/{}').format(role.workspace.id)}">${role.workspace.name}</a></td>
11
-        <td><span style="${role.style}"><i class="fa ${role.icon}"></i> ${role.label}</span></td>
12
-        % if enable_link or disable_link:
11
+
12
+        % if role_types:
13
+            ## <td>${BUTTON.SECURED_ROLE_SELECTOR(fake_api.current_user, result.workspace, member, fake_api.role_types)}</td>
14
+            <td><span style="${role.style}"><i class="fa ${role.icon}"></i> ${role.label}</span></td>
15
+        % else:
16
+            <td><span style="${role.style}"><i class="fa ${role.icon}"></i> ${role.label}</span></td>
17
+        % endif
18
+
19
+        <%
20
+            user_is_himself = current_user.id == role.user.id
21
+            user_is_manager = h.user_role(current_user, role.workspace) >= 8
22
+            ## allow user to change notification status only if current user is manager on the given workspace
23
+        %>
24
+
25
+        % if (enable_link or disable_link) and (user_is_himself or user_is_manager) :
13 26
             <td>${SPAN.NOTIFICATION_SUBSCRIBED(role.user, role.workspace, role.notifications_subscribed, enable_link, disable_link)}</td>
14 27
         % else:
15 28
             <td>${SPAN.NOTIFICATION_SUBSCRIBED(role.user, role.workspace, role.notifications_subscribed)}</td>
@@ -23,7 +36,20 @@
23 36
         <td class="text-right">${member.id}</td>
24 37
         <td ><a href="${tg.url('/admin/users/{}'.format(member.id))}">${member.name}</a></td>
25 38
         <td>${BUTTON.SECURED_ROLE_SELECTOR(fake_api.current_user, result.workspace, member, fake_api.role_types)}</td>
26
-        <td>${SPAN.NOTIFICATION_SUBSCRIBED(member, workspace, member.notifications_subscribed)}</td>
39
+        <%
40
+            user_is_himself = current_user.id == member.id
41
+            user_is_manager = h.user_role(current_user, workspace) >= 8
42
+            ## allow user to change notification status only if current user is manager on the given workspace
43
+
44
+            enable_link = '/admin/users/{user}/workspaces/{workspace}/enable_notifications?next_url=/admin/workspaces/{workspace}'
45
+            disable_link = '/admin/users/{user}/workspaces/{workspace}/disable_notifications?next_url=/admin/workspaces/{workspace}'
46
+        %>
47
+        % if (enable_link or disable_link) and (user_is_himself or user_is_manager) :
48
+            <td>${SPAN.NOTIFICATION_SUBSCRIBED(member, workspace, member.notifications_subscribed, enable_link, disable_link)}</td>
49
+        % else:
50
+            <td>${SPAN.NOTIFICATION_SUBSCRIBED(member, workspace, member.notifications_subscribed)}</td>
51
+        % endif
52
+
27 53
         <td><a title="${_('Remove this user from the current workspace')}" class="t-less-visible t-red-on-hover t-red btn btn-default btn-xs" href="${tg.url('/admin/workspaces/{}/roles/{}/delete'.format(result.workspace.id, member.id))}">${ICON.FA('fa-remove fa-fw')}</a></td>
28 54
     </tr>
29 55
 </%def>

+ 4 - 0
tracim/tracim/tests/library/test_content_api.py View File

@@ -342,6 +342,7 @@ class TestContentApi(TestStandard):
342 342
 
343 343
         RoleApi(user1).create_one(user2, workspace,
344 344
                                   UserRoleInWorkspace.CONTENT_MANAGER,
345
+                                  with_notif=False,
345 346
                                   flush=True)
346 347
 
347 348
         # Test starts here
@@ -405,6 +406,7 @@ class TestContentApi(TestStandard):
405 406
 
406 407
         RoleApi(user1).create_one(user2, workspace,
407 408
                                   UserRoleInWorkspace.CONTENT_MANAGER,
409
+                                  with_notif=True,
408 410
                                   flush=True)
409 411
 
410 412
         # Test starts here
@@ -471,6 +473,7 @@ class TestContentApi(TestStandard):
471 473
 
472 474
         RoleApi(user1).create_one(user2, workspace,
473 475
                                   UserRoleInWorkspace.CONTENT_MANAGER,
476
+                                  with_notif=True,
474 477
                                   flush=True)
475 478
 
476 479
         # show archived is used at the top end of the test
@@ -545,6 +548,7 @@ class TestContentApi(TestStandard):
545 548
 
546 549
         RoleApi(user1).create_one(user2, workspace,
547 550
                                   UserRoleInWorkspace.CONTENT_MANAGER,
551
+                                  with_notif=True,
548 552
                                   flush=True)
549 553
 
550 554
         # show archived is used at the top end of the test

+ 6 - 0
tracim/tracim/tests/library/test_helpers.py View File

@@ -25,9 +25,15 @@ class TestHelpers(TestStandard):
25 25
 
26 26
         h.CFG.DATA_UPDATE_ALLOWED_DURATION = 0
27 27
         item.created = datetime.datetime.now() - datetime.timedelta(0, 10)
28
+
29
+        item.type = DictLikeClass({'id': 5})
30
+        eq_(False, h.is_item_still_editable(item))
31
+
32
+        item.type.id = 'comment'
28 33
         eq_(False, h.is_item_still_editable(item))
29 34
 
30 35
         h.CFG.DATA_UPDATE_ALLOWED_DURATION = -1
36
+        item.type.id = 'comment'
31 37
         item.created = datetime.datetime.now() - datetime.timedelta(0, 10)
32 38
         eq_(True, h.is_item_still_editable(item))
33 39