瀏覽代碼

add content read/not read status for each user. this requires a database upgrade

Damien ACCORSI 9 年之前
父節點
當前提交
60d9d5870e

+ 28 - 0
tracim/migration/versions/43a323cc661_add_read_not_read_content_status.py 查看文件

1
+"""add read/not read content status
2
+
3
+Revision ID: 43a323cc661
4
+Revises: None
5
+Create Date: 2015-08-26 11:23:03.466554
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = '43a323cc661'
11
+down_revision = None
12
+
13
+from alembic import op
14
+import sqlalchemy as sa
15
+
16
+
17
+def upgrade():
18
+    op.create_table(
19
+        'revision_read_status',
20
+        sa.Column('revision_id', sa.Integer, sa.ForeignKey('content_revisions.revision_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True),
21
+        sa.Column('user_id', sa.Integer, sa.ForeignKey('users.user_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True),
22
+        sa.Column('view_datetime', sa.DateTime, server_default=sa.func.now(), unique=False, nullable=False)
23
+    )
24
+
25
+
26
+def downgrade():
27
+    op.drop_table('revision_read_status')
28
+

+ 44 - 1
tracim/tracim/controllers/__init__.py 查看文件

6
 from tg import RestController
6
 from tg import RestController
7
 from tg import tmpl_context
7
 from tg import tmpl_context
8
 from tg.i18n import ugettext as _
8
 from tg.i18n import ugettext as _
9
+from tg.predicates import not_anonymous
9
 
10
 
10
 from tracim.lib import CST
11
 from tracim.lib import CST
11
 from tracim.lib.base import BaseController
12
 from tracim.lib.base import BaseController
341
             tg.flash(msg, CST.STATUS_ERROR)
342
             tg.flash(msg, CST.STATUS_ERROR)
342
             tg.redirect(next_url)
343
             tg.redirect(next_url)
343
 
344
 
344
-
345
     @tg.require(current_user_is_content_manager())
345
     @tg.require(current_user_is_content_manager())
346
     @tg.expose()
346
     @tg.expose()
347
     def put_archive_undo(self, item_id):
347
     def put_archive_undo(self, item_id):
415
             tg.flash(msg, CST.STATUS_ERROR)
415
             tg.flash(msg, CST.STATUS_ERROR)
416
             tg.redirect(back_url)
416
             tg.redirect(back_url)
417
 
417
 
418
+    @tg.expose()
419
+    @tg.require(not_anonymous())
420
+    def put_read(self, item_id):
421
+        item_id = int(item_id)
422
+        content_api = ContentApi(tmpl_context.current_user, True, True) # Here we do not filter deleted items
423
+        item = content_api.get_one(item_id, self._item_type, tmpl_context.workspace)
424
+
425
+        item_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
426
+
427
+        try:
428
+            msg = _('{} marked as read.').format(self._item_type_label)
429
+            content_api.mark_read(item)
430
+
431
+            tg.flash(msg, CST.STATUS_OK)
432
+            tg.redirect(item_url)
433
+
434
+        except ValueError as e:
435
+            logger.debug(self, 'Exception: {}'.format(e.__str__))
436
+            msg = _('{} not marked as read: {}').format(self._item_type_label, str(e))
437
+            tg.flash(msg, CST.STATUS_ERROR)
438
+            tg.redirect(item_url)
439
+
440
+    @tg.expose()
441
+    @tg.require(not_anonymous())
442
+    def put_unread(self, item_id):
443
+        item_id = int(item_id)
444
+        content_api = ContentApi(tmpl_context.current_user, True, True) # Here we do not filter deleted items
445
+        item = content_api.get_one(item_id, self._item_type, tmpl_context.workspace)
446
+
447
+        item_url = self._std_url.format(item.workspace_id, item.parent_id, item.content_id)
448
+
449
+        try:
450
+            msg = _('{} marked unread.').format(self._item_type_label)
451
+            content_api.mark_unread(item)
452
+
453
+            tg.flash(msg, CST.STATUS_OK)
454
+            tg.redirect(item_url)
455
+
456
+        except ValueError as e:
457
+            logger.debug(self, 'Exception: {}'.format(e.__str__))
458
+            msg = _('{} not marked unread: {}').format(self._item_type_label, str(e))
459
+            tg.flash(msg, CST.STATUS_ERROR)
460
+            tg.redirect(item_url)
418
 
461
 
419
 class StandardController(BaseController):
462
 class StandardController(BaseController):
420
 
463
 

+ 8 - 4
tracim/tracim/controllers/content.py 查看文件

172
         workspace = tmpl_context.workspace
172
         workspace = tmpl_context.workspace
173
         workspace_id = tmpl_context.workspace_id
173
         workspace_id = tmpl_context.workspace_id
174
 
174
 
175
-        current_user_content = Context(CTX.CURRENT_USER).toDict(user)
175
+        current_user_content = Context(CTX.CURRENT_USER,
176
+                                       current_user=user).toDict(user)
176
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
177
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
177
 
178
 
178
         content_api = ContentApi(user)
179
         content_api = ContentApi(user)
183
 
184
 
184
         fake_api_breadcrumb = self.get_breadcrumb(file_id)
185
         fake_api_breadcrumb = self.get_breadcrumb(file_id)
185
         fake_api_content = DictLikeClass(breadcrumb=fake_api_breadcrumb, current_user=current_user_content)
186
         fake_api_content = DictLikeClass(breadcrumb=fake_api_breadcrumb, current_user=current_user_content)
186
-        fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
187
+        fake_api = Context(CTX.FOLDER,
188
+                           current_user=user).toDict(fake_api_content)
187
 
189
 
188
-        dictified_file = Context(self._get_one_context).toDict(file, 'file')
190
+        dictified_file = Context(self._get_one_context,
191
+                                 current_user=user).toDict(file, 'file')
189
         return DictLikeClass(result = dictified_file, fake_api=fake_api)
192
         return DictLikeClass(result = dictified_file, fake_api=fake_api)
190
 
193
 
191
     @tg.require(current_user_is_reader())
194
     @tg.require(current_user_is_reader())
670
         workspace = tmpl_context.workspace
673
         workspace = tmpl_context.workspace
671
         workspace_id = tmpl_context.workspace_id
674
         workspace_id = tmpl_context.workspace_id
672
 
675
 
673
-        current_user_content = Context(CTX.CURRENT_USER).toDict(user)
676
+        current_user_content = Context(CTX.CURRENT_USER,
677
+                                       current_user=user).toDict(user)
674
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
678
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
675
 
679
 
676
         content_api = ContentApi(user)
680
         content_api = ContentApi(user)

+ 3 - 0
tracim/tracim/controllers/root.py 查看文件

129
         last_active_contents = ContentApi(user).get_last_active(None, ContentType.Any, None)
129
         last_active_contents = ContentApi(user).get_last_active(None, ContentType.Any, None)
130
         fake_api.last_actives = Context(CTX.CONTENT_LIST).toDict(last_active_contents, 'contents', 'nb')
130
         fake_api.last_actives = Context(CTX.CONTENT_LIST).toDict(last_active_contents, 'contents', 'nb')
131
 
131
 
132
+        unread_contents = ContentApi(user).get_last_unread(None, ContentType.Any, None)
133
+        fake_api.last_unread = Context(CTX.CONTENT_LIST).toDict(unread_contents, 'contents', 'nb')
134
+
132
         # INFO - D.A. - 2015-05-20
135
         # INFO - D.A. - 2015-05-20
133
         # For now, we do not have favorties and read/unread status
136
         # For now, we do not have favorties and read/unread status
134
         # so we only show:
137
         # so we only show:

+ 129 - 5
tracim/tracim/lib/content.py 查看文件

2
 
2
 
3
 __author__ = 'damien'
3
 __author__ = 'damien'
4
 
4
 
5
+import datetime
5
 import re
6
 import re
6
 
7
 
7
 import tg
8
 import tg
12
 from sqlalchemy.orm import joinedload
13
 from sqlalchemy.orm import joinedload
13
 from sqlalchemy.orm.attributes import get_history
14
 from sqlalchemy.orm.attributes import get_history
14
 from sqlalchemy import desc
15
 from sqlalchemy import desc
16
+from sqlalchemy import distinct
15
 from sqlalchemy import not_
17
 from sqlalchemy import not_
16
 from sqlalchemy import or_
18
 from sqlalchemy import or_
17
 from tracim.lib import cmp_to_key
19
 from tracim.lib import cmp_to_key
26
 from tracim.model.data import Content
28
 from tracim.model.data import Content
27
 from tracim.model.data import ContentType
29
 from tracim.model.data import ContentType
28
 from tracim.model.data import NodeTreeItem
30
 from tracim.model.data import NodeTreeItem
29
-from tracim.model.data import Workspace
31
+from tracim.model.data import RevisionReadStatus
30
 from tracim.model.data import UserRoleInWorkspace
32
 from tracim.model.data import UserRoleInWorkspace
33
+from tracim.model.data import Workspace
31
 
34
 
32
 def compare_content_for_sorting_by_type_and_name(content1: Content,
35
 def compare_content_for_sorting_by_type_and_name(content1: Content,
33
                                                  content2: Content):
36
                                                  content2: Content):
156
 
159
 
157
         return result
160
         return result
158
 
161
 
162
+    def __revisions_real_base_query(self, workspace: Workspace=None):
163
+        result = DBSession.query(ContentRevisionRO)
164
+
165
+        if workspace:
166
+            result = result.filter(ContentRevisionRO.workspace_id==workspace.workspace_id)
167
+
168
+        if self._user:
169
+            user = DBSession.query(User).get(self._user_id)
170
+            # Filter according to user workspaces
171
+            workspace_ids = [r.workspace_id for r in user.roles \
172
+                             if r.role>=UserRoleInWorkspace.READER]
173
+            result = result.filter(ContentRevisionRO.workspace_id.in_(workspace_ids))
174
+
175
+        return result
176
+
177
+    def _revisions_base_query(self, workspace: Workspace=None):
178
+        result = self.__revisions_real_base_query(workspace)
179
+
180
+        if not self._show_deleted:
181
+            result = result.filter(ContentRevisionRO.is_deleted==False)
182
+
183
+        if not self._show_archived:
184
+            result = result.filter(ContentRevisionRO.is_archived==False)
185
+
186
+        return result
187
+
159
     def _hard_filtered_base_query(self, workspace: Workspace=None):
188
     def _hard_filtered_base_query(self, workspace: Workspace=None):
160
         """
189
         """
161
         If set to True, then filterign on is_deleted and is_archived will also
190
         If set to True, then filterign on is_deleted and is_archived will also
303
 
332
 
304
         return resultset.all()
333
         return resultset.all()
305
 
334
 
306
-    def get_last_active(self, parent_id: int, content_type: str, workspace: Workspace=None, limit=10) -> Content:
335
+    def get_last_active(self, parent_id: int, content_type: str, workspace: Workspace=None, limit=10) -> [Content]:
307
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
336
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
308
         assert content_type is not None# DYN_REMOVE
337
         assert content_type is not None# DYN_REMOVE
309
         assert isinstance(content_type, str) # DYN_REMOVE
338
         assert isinstance(content_type, str) # DYN_REMOVE
335
 
364
 
336
         return result
365
         return result
337
 
366
 
367
+    def get_last_unread(self, parent_id: int, content_type: str,
368
+                        workspace: Workspace=None, limit=10) -> [Content]:
369
+        assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
370
+        assert content_type is not None# DYN_REMOVE
371
+        assert isinstance(content_type, str) # DYN_REMOVE
372
+
373
+        read_revision_ids = DBSession.query(RevisionReadStatus.revision_id) \
374
+            .filter(RevisionReadStatus.user_id==self._user_id)
375
+
376
+        not_read_revisions = self._revisions_base_query(workspace) \
377
+            .filter(~ContentRevisionRO.revision_id.in_(read_revision_ids)) \
378
+            .subquery()
379
+
380
+        not_read_content_ids = DBSession.query(
381
+            distinct(not_read_revisions.c.content_id)).all()
382
+
383
+        not_read_contents = self._base_query(workspace) \
384
+            .filter(Content.content_id.in_(not_read_content_ids)) \
385
+            .order_by(desc(Content.updated))
386
+
387
+        if content_type != ContentType.Any:
388
+            not_read_contents = not_read_contents.filter(
389
+                Content.type==content_type)
390
+        else:
391
+            not_read_contents = not_read_contents.filter(
392
+                Content.type!=ContentType.Folder)
393
+
394
+        if parent_id:
395
+            not_read_contents = not_read_contents.filter(
396
+                Content.parent_id==parent_id)
397
+
398
+        result = []
399
+        for item in not_read_contents:
400
+            new_item = None
401
+            if ContentType.Comment == item.type:
402
+                new_item = item.parent
403
+            else:
404
+                new_item = item
405
+
406
+            # INFO - D.A. - 2015-05-20
407
+            # We do not want to show only one item if the last 10 items are
408
+            # comments about one thread for example
409
+            if new_item not in result:
410
+                result.append(new_item)
411
+
412
+            if len(result) >= limit:
413
+                break
414
+
415
+        return result
416
+
338
     def set_allowed_content(self, folder: Content, allowed_content_dict:dict):
417
     def set_allowed_content(self, folder: Content, allowed_content_dict:dict):
339
         """
418
         """
340
         :param folder: the given folder instance
419
         :param folder: the given folder instance
350
         properties = dict(allowed_content = allowed_content_dict)
429
         properties = dict(allowed_content = allowed_content_dict)
351
         folder.properties = properties
430
         folder.properties = properties
352
 
431
 
353
-
354
     def set_status(self, content: Content, new_status: str):
432
     def set_status(self, content: Content, new_status: str):
355
         if new_status in ContentStatus.allowed_values():
433
         if new_status in ContentStatus.allowed_values():
356
             content.status = new_status
434
             content.status = new_status
358
         else:
436
         else:
359
             raise ValueError('The given value {} is not allowed'.format(new_status))
437
             raise ValueError('The given value {} is not allowed'.format(new_status))
360
 
438
 
361
-
362
     def move(self, item: Content,
439
     def move(self, item: Content,
363
              new_parent: Content,
440
              new_parent: Content,
364
              must_stay_in_same_workspace:bool=True,
441
              must_stay_in_same_workspace:bool=True,
422
         content.is_deleted = False
499
         content.is_deleted = False
423
         content.revision_type = ActionDescription.UNDELETION
500
         content.revision_type = ActionDescription.UNDELETION
424
 
501
 
502
+    def mark_read(self, content: Content,
503
+                  read_datetime: datetime=None,
504
+                  do_flush=True) -> Content:
505
+
506
+        assert self._user
507
+        assert content
508
+
509
+        # The algorithm is:
510
+        # 1. define the read datetime
511
+        # 2. update all revisions related to current Content
512
+        # 3. do the same for all child revisions
513
+        #    (ie parent_id is content_id of current content)
514
+
515
+        if not read_datetime:
516
+            read_datetime = datetime.datetime.now()
517
+
518
+        viewed_revisions = DBSession.query(ContentRevisionRO) \
519
+            .filter(ContentRevisionRO.content_id==content.content_id).all()
520
+
521
+        for revision in viewed_revisions:
522
+            revision.read_by[self._user] = read_datetime
523
+
524
+        for child in content.get_valid_children():
525
+            self.mark_read(child, read_datetime=read_datetime, do_flush=False)
526
+
527
+        if do_flush:
528
+            self.flush()
529
+
530
+        return content
531
+
532
+    def mark_unread(self, content: Content, do_flush=True) -> Content:
533
+        assert self._user
534
+        assert content
535
+
536
+        revisions = DBSession.query(ContentRevisionRO) \
537
+            .filter(ContentRevisionRO.content_id==content.content_id).all()
538
+
539
+        for revision in revisions:
540
+            del revision.read_by[self._user]
541
+
542
+        for child in content.get_valid_children():
543
+            self.mark_unread(child, do_flush=False)
544
+
545
+        if do_flush:
546
+            self.flush()
547
+
548
+        return content
549
+
425
     def flush(self):
550
     def flush(self):
426
         DBSession.flush()
551
         DBSession.flush()
427
 
552
 
443
         if action_description:
568
         if action_description:
444
             content.revision_type = action_description
569
             content.revision_type = action_description
445
 
570
 
446
-
447
         if do_flush:
571
         if do_flush:
448
             DBSession.add(content)
572
             DBSession.add(content)
449
             DBSession.flush()
573
             DBSession.flush()

+ 98 - 1
tracim/tracim/model/data.py 查看文件

8
 import datetime as datetime_root
8
 import datetime as datetime_root
9
 import json
9
 import json
10
 
10
 
11
+from sqlalchemy.ext.associationproxy import association_proxy
11
 from sqlalchemy import Column
12
 from sqlalchemy import Column
13
+from sqlalchemy import func
12
 from sqlalchemy import ForeignKey
14
 from sqlalchemy import ForeignKey
13
 from sqlalchemy import Sequence
15
 from sqlalchemy import Sequence
14
 
16
 
15
 from sqlalchemy.ext.hybrid import hybrid_property
17
 from sqlalchemy.ext.hybrid import hybrid_property
16
 
18
 
19
+from sqlalchemy.orm import backref
17
 from sqlalchemy.orm import relationship
20
 from sqlalchemy.orm import relationship
18
 from sqlalchemy.orm import deferred
21
 from sqlalchemy.orm import deferred
22
+from sqlalchemy.orm.collections import attribute_mapped_collection
19
 
23
 
20
 from sqlalchemy.types import Boolean
24
 from sqlalchemy.types import Boolean
21
 from sqlalchemy.types import DateTime
25
 from sqlalchemy.types import DateTime
468
     parent = relationship('Content', remote_side=[content_id], backref='children')
472
     parent = relationship('Content', remote_side=[content_id], backref='children')
469
     owner = relationship('User', remote_side=[User.user_id])
473
     owner = relationship('User', remote_side=[User.user_id])
470
 
474
 
471
-    def get_valid_children(self, content_types: list=None):
475
+    def get_valid_children(self, content_types: list=None) -> ['Content']:
472
         for child in self.children:
476
         for child in self.children:
473
             if not child.is_deleted and not child.is_archived:
477
             if not child.is_deleted and not child.is_archived:
474
                 if not content_types or child.type in content_types:
478
                 if not content_types or child.type in content_types:
556
                 last_revision_date = child.updated
560
                 last_revision_date = child.updated
557
         return last_revision_date
561
         return last_revision_date
558
 
562
 
563
+    def has_new_information_for(self, user: User) -> bool:
564
+        """
565
+        :param user: the session current user
566
+        :return: bool, True if there is new information for given user else False
567
+                       False if the user is None
568
+        """
569
+        revision = self.get_current_revision()
570
+
571
+        if not user:
572
+            return False
573
+
574
+        if user not in revision.read_by.keys():
575
+            # The user did not read this item, so yes!
576
+            return True
577
+
578
+        for child in self.get_valid_children():
579
+            if child.has_new_information_for(user):
580
+                return True
581
+
582
+        return False
583
+
559
     def get_comments(self):
584
     def get_comments(self):
560
         children = []
585
         children = []
561
         for child in self.children:
586
         for child in self.children:
589
 
614
 
590
         return None
615
         return None
591
 
616
 
617
+    def get_current_revision(self) -> 'ContentRevisionRO':
618
+        # TODO - D.A. - 2015-08-26
619
+        # This code is not efficient at all!!!
620
+        # We should get the revision id directly from the view
621
+        rev_ids = [revision.revision_id for revision in self.revisions]
622
+        rev_ids.sort()
623
+
624
+        for revision in self.revisions:
625
+            if revision.revision_id == rev_ids[-1]:
626
+                return revision
627
+
628
+        return None
629
+
592
     def description_as_raw_text(self):
630
     def description_as_raw_text(self):
593
         # 'html.parser' fixes a hanging bug
631
         # 'html.parser' fixes a hanging bug
594
         # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
632
         # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
619
                                key=lambda event: event.created, reverse=True)
657
                                key=lambda event: event.created, reverse=True)
620
         return sorted_events
658
         return sorted_events
621
 
659
 
660
+    @classmethod
661
+    def format_path(cls, url_template: str, content: 'Content') -> str:
662
+        wid = content.workspace.workspace_id
663
+        fid = content.parent_id  # May be None if no parent
664
+        ctype = content.type
665
+        cid = content.content_id
666
+        return url_template.format(wid=wid, fid=fid, ctype=ctype, cid=cid)
667
+
622
 
668
 
623
 class ContentChecker(object):
669
 class ContentChecker(object):
624
 
670
 
693
     def get_last_action(self) -> ActionDescription:
739
     def get_last_action(self) -> ActionDescription:
694
         return ActionDescription(self.revision_type)
740
         return ActionDescription(self.revision_type)
695
 
741
 
742
+    # Read by must be used like this:
743
+    # read_datetime = revision.ready_by[<User instance>]
744
+    # if user did not read the content, then a key error is raised
745
+    read_by = association_proxy(
746
+        'revision_read_statuses',  # name of the attribute
747
+        'view_datetime',  # attribute the value is taken from
748
+        creator=lambda k, v: \
749
+            RevisionReadStatus(user=k, view_datetime=v)
750
+    )
751
+
752
+    def has_new_information_for(self, user: User) -> bool:
753
+        """
754
+        :param user: the session current user
755
+        :return: bool, True if there is new information for given user else False
756
+                       False if the user is None
757
+        """
758
+        if not user:
759
+            return False
760
+
761
+        if user not in self.read_by.keys():
762
+            return True
763
+
764
+        return False
765
+
766
+class RevisionReadStatus(DeclarativeBase):
767
+
768
+    __tablename__ = 'revision_read_status'
769
+
770
+    revision_id = Column(Integer, ForeignKey('content_revisions.revision_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
771
+    user_id = Column(Integer, ForeignKey('users.user_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
772
+    view_datetime = Column(DateTime, unique=False, nullable=False, server_default=func.now())
773
+
774
+    # content_revision = relationship(
775
+    #     'ContentRevisionRO',
776
+    #     remote_side=[ContentRevisionRO.revision_id],
777
+    #     backref='revision_read_statuses')
778
+
779
+    content_revision = relationship(
780
+        'ContentRevisionRO',
781
+        backref=backref(
782
+            'revision_read_statuses',
783
+            collection_class=attribute_mapped_collection('user'),
784
+            cascade='all, delete-orphan'
785
+        ))
786
+
787
+    user = relationship('User', backref=backref(
788
+        'revision_readers',
789
+        collection_class=attribute_mapped_collection('view_datetime'),
790
+        cascade='all, delete-orphan'
791
+    ))
792
+
696
 
793
 
697
 class NodeTreeItem(object):
794
 class NodeTreeItem(object):
698
     """
795
     """

+ 27 - 7
tracim/tracim/model/serializers.py 查看文件

10
 from tg.i18n import ugettext as _
10
 from tg.i18n import ugettext as _
11
 from tg.util import LazyString
11
 from tg.util import LazyString
12
 from tracim.lib.base import logger
12
 from tracim.lib.base import logger
13
+from tracim.lib.user import UserStaticApi
13
 from tracim.lib.utils import exec_time_monitor
14
 from tracim.lib.utils import exec_time_monitor
14
 from tracim.model.auth import Profile
15
 from tracim.model.auth import Profile
15
 from tracim.model.auth import User
16
 from tracim.model.auth import User
144
 
145
 
145
             raise ContextConverterNotFoundException(context_string, model_class)
146
             raise ContextConverterNotFoundException(context_string, model_class)
146
 
147
 
147
-    def __init__(self, context_string, base_url=''):
148
+    def __init__(self, context_string, base_url='', current_user=None):
148
         """
149
         """
149
         """
150
         """
150
         self.context_string = context_string
151
         self.context_string = context_string
152
+        self._current_user = current_user  # Allow to define the current user if any
153
+        if not current_user:
154
+            self._current_user = UserStaticApi.get_current_user()
155
+
151
         self._base_url = base_url # real root url like http://mydomain.com:8080
156
         self._base_url = base_url # real root url like http://mydomain.com:8080
152
 
157
 
153
     def url(self, base_url='/', params=None, qualified=False) -> str:
158
     def url(self, base_url='/', params=None, qualified=False) -> str:
157
             url = '{}{}'.format(self._base_url, url)
162
             url = '{}{}'.format(self._base_url, url)
158
         return  url
163
         return  url
159
 
164
 
165
+    def get_user(self):
166
+        return self._current_user
167
+
160
     def toDict(self, serializableObject, key_value_for_a_list_object='', key_value_for_list_item_nb=''):
168
     def toDict(self, serializableObject, key_value_for_a_list_object='', key_value_for_list_item_nb=''):
161
         """
169
         """
162
         Converts a given object into a recursive dictionnary like structure.
170
         Converts a given object into a recursive dictionnary like structure.
351
 @pod_serializer(Content, CTX.PAGE)
359
 @pod_serializer(Content, CTX.PAGE)
352
 @pod_serializer(Content, CTX.FILE)
360
 @pod_serializer(Content, CTX.FILE)
353
 def serialize_node_for_page(content: Content, context: Context):
361
 def serialize_node_for_page(content: Content, context: Context):
362
+
354
     if content.type in (ContentType.Page, ContentType.File) :
363
     if content.type in (ContentType.Page, ContentType.File) :
355
         data_container = content
364
         data_container = content
356
 
365
 
366
             parent=context.toDict(content.parent),
375
             parent=context.toDict(content.parent),
367
             workspace=context.toDict(content.workspace),
376
             workspace=context.toDict(content.workspace),
368
             type=content.type,
377
             type=content.type,
369
-
378
+            is_new=content.has_new_information_for(context.get_user()),
370
             content=data_container.description,
379
             content=data_container.description,
371
             created=data_container.created,
380
             created=data_container.created,
372
             label=data_container.label,
381
             label=data_container.label,
376
             links=context.toDict(content.extract_links_from_content(data_container.description)),
385
             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)),
386
             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,
387
             selected_revision='latest' if content.revision_to_serialize<=0 else content.revision_to_serialize,
379
-            history=Context(CTX.CONTENT_HISTORY).toDict(content.get_history())
388
+            history=Context(CTX.CONTENT_HISTORY).toDict(content.get_history()),
389
+            urls = context.toDict({
390
+                'mark_read': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_read', content)),
391
+                'mark_unread': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_unread', content))
392
+            })
380
         )
393
         )
381
 
394
 
382
         if content.type==ContentType.File:
395
         if content.type==ContentType.File:
389
 
402
 
390
     if content.type==ContentType.Folder:
403
     if content.type==ContentType.Folder:
391
         value = DictLikeClass(
404
         value = DictLikeClass(
392
-            id = content.content_id,
393
-            label = content.label,
405
+            id=content.content_id,
406
+            label=content.label,
407
+            is_new=content.has_new_information_for(context.get_user()),
394
         )
408
         )
395
         return value
409
         return value
396
 
410
 
418
         created=event.created,
432
         created=event.created,
419
         created_as_delta=event.created_as_delta(),
433
         created_as_delta=event.created_as_delta(),
420
         content=event.content,
434
         content=event.content,
435
+        is_new=event.ref_object.has_new_information_for(context.get_user()),
421
         urls = urls
436
         urls = urls
422
     )
437
     )
423
 
438
 
424
-
425
 @pod_serializer(Content, CTX.THREAD)
439
 @pod_serializer(Content, CTX.THREAD)
426
 def serialize_node_for_page(item: Content, context: Context):
440
 def serialize_node_for_page(item: Content, context: Context):
427
     if item.type==ContentType.Thread:
441
     if item.type==ContentType.Thread:
439
             type = item.type,
453
             type = item.type,
440
             workspace = context.toDict(item.workspace),
454
             workspace = context.toDict(item.workspace),
441
             comments = reversed(context.toDict(item.get_comments())),
455
             comments = reversed(context.toDict(item.get_comments())),
442
-            history = Context(CTX.CONTENT_HISTORY).toDict(item.get_history())
456
+            is_new=item.has_new_information_for(context.get_user()),
457
+            history = Context(CTX.CONTENT_HISTORY).toDict(item.get_history()),
458
+            urls = context.toDict({
459
+                'mark_read': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_read', item)),
460
+                'mark_unread': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_unread', item))
461
+            }),
443
         )
462
         )
444
 
463
 
445
     if item.type==ContentType.Comment:
464
     if item.type==ContentType.Comment:
446
         return DictLikeClass(
465
         return DictLikeClass(
466
+            is_new=item.has_new_information_for(context.get_user()),
447
             content = item.description,
467
             content = item.description,
448
             created = item.created,
468
             created = item.created,
449
             created_as_delta = item.created_as_delta(),
469
             created_as_delta = item.created_as_delta(),

+ 7 - 1
tracim/tracim/public/assets/css/dashboard.css 查看文件

297
     border-bottom-width: 4px;
297
     border-bottom-width: 4px;
298
 }
298
 }
299
 
299
 
300
-h3 { background-color: #f5f5f5;}
300
+/* FIXME - 2015-09-01 - D.A. - CAN WE REMOVE THIS ? h3 { background-color: #f5f5f5;}*/
301
 
301
 
302
 
302
 
303
 .sidebar .list-group-item { background-color: transparent; }
303
 .sidebar .list-group-item { background-color: transparent; }
354
 #t-full-app-alert-message-id > div.alert {
354
 #t-full-app-alert-message-id > div.alert {
355
     box-shadow: 0px 0px 5px 5px rgba(0, 0, 0, 0.3);
355
     box-shadow: 0px 0px 5px 5px rgba(0, 0, 0, 0.3);
356
 }
356
 }
357
+
358
+tr.t-is-new-content td, div.row.t-is-new-content {
359
+    background-color: #DFF0D8;
360
+}
361
+
362
+.panel-heading > h3 { font-size: 1.5em;}

+ 6 - 0
tracim/tracim/templates/file/toolbar.mak 查看文件

1
 <%namespace name="TIM" file="tracim.templates.pod"/>
1
 <%namespace name="TIM" file="tracim.templates.pod"/>
2
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
2
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
3
+<%namespace name="BUTTON" file="tracim.templates.widgets.button"/>
3
 
4
 
4
 <%def name="SECURED_FILE(user, workspace, file)">
5
 <%def name="SECURED_FILE(user, workspace, file)">
6
+    <div class="btn-group btn-group-vertical text-center">
7
+        ${BUTTON.MARK_CONTENT_READ_OR_UNREAD(user, workspace, file)}
8
+    </div>
9
+    <p></p>
10
+
5
     <% download_url = tg.url('/workspaces/{}/folders/{}/files/{}/download?revision_id={}'.format(result.file.workspace.id, result.file.parent.id,result.file.id,result.file.selected_revision)) %>
11
     <% download_url = tg.url('/workspaces/{}/folders/{}/files/{}/download?revision_id={}'.format(result.file.workspace.id, result.file.parent.id,result.file.id,result.file.selected_revision)) %>
6
     <% edit_disabled = ('', 'disabled')[file.selected_revision!='latest' or file.status.id[:6]=='closed'] %>
12
     <% edit_disabled = ('', 'disabled')[file.selected_revision!='latest' or file.status.id[:6]=='closed'] %>
7
     <% delete_or_archive_disabled = ('', 'disabled')[file.selected_revision!='latest'] %> 
13
     <% delete_or_archive_disabled = ('', 'disabled')[file.selected_revision!='latest'] %> 

+ 72 - 91
tracim/tracim/templates/home.mak 查看文件

39
 <div class="container-fluid">
39
 <div class="container-fluid">
40
     <div class="row-fluid">
40
     <div class="row-fluid">
41
         <div>
41
         <div>
42
-            <div class="row">
42
+            ## NOT READ
43
+            <div class="row" id="unread-content-panel">
43
                 <div class="col-md-offset-3 col-sm-7">
44
                 <div class="col-md-offset-3 col-sm-7">
44
                     <div class="row t-spacer-above">
45
                     <div class="row t-spacer-above">
45
-##                        <div class="col-sm-6">
46
-##                            <div class="panel panel-default">
47
-##                              <div class="panel-heading">
48
-##                                <h3 class="panel-title"><i class="fa fa-eye-slash"></i> ${_('Unread content')}</h3>
49
-##                              </div>
50
-##                              <div class="panel-body">
51
-##                                Panel content
52
-##                              </div>
53
-##                            </div>
54
-##                        </div>
46
+                        <div class="col-sm-12">
47
+                            <div class="t-half-spacer-above">
48
+                                <div class="panel panel-success">
49
+                                    <div class="panel-heading">
50
+                                        <h3 class="panel-title"><i class="fa fa-fw fa-eye-slash"></i> ${_('Unread')}</h3>
51
+                                    </div>
52
+                                    <div class="panel-body">
53
+                                        % if fake_api.last_unread.nb <= 0:
54
+                                            ${P.EMPTY_CONTENT(_('No unread content.'))}
55
+                                        % else:
56
+                                            <table class="table table-hover">
57
+                                                % for item in fake_api.last_unread.contents:
58
+                                                    <tr>
59
+                                                        <td>
60
+                                                            <i class="${item.type.icon} fa-fw ${item.type.color}"></i>
61
+                                                            <a href="${item.url}">${item.label}</a>
62
+                                                            <br/>
63
+                                                            <span class="t-less-visible">${item.workspace.label}</span>
64
+                                                        </td>
65
+                                                        <td title="${_('Last activity: {datetime}').format(datetime=item.last_activity.label)}">
66
+                                                            ${item.last_activity.delta}
67
+                                                        </td>
68
+                                                    </tr>
69
+                                                % endfor
70
+                                            </table>
71
+                                        % endif
72
+                                     </div>
73
+                                 </div>
74
+                            </div>
75
+                        </div>
76
+                    </div>
77
+                </div>
78
+            </div>
55
 
79
 
80
+            ## RECENT ACTIVITY
81
+            <div class="row" id="recent-activity-panel">
82
+                <div class="col-md-offset-3 col-sm-7">
83
+                    <div class="row t-spacer-above">
56
                         <div class="col-sm-12">
84
                         <div class="col-sm-12">
57
-                            <div class="panel panel-default">
58
-                                <div class="panel-heading">
59
-                                    <h3 class="panel-title"><i class="fa fa-line-chart"></i> ${_('Recent activity')}</h3>
85
+                            <div class="t-half-spacer-above">
86
+                                <div class="panel panel-warning">
87
+                                    <div class="panel-heading">
88
+                                        <h3 class="panel-title"><i class="fa fa-fw fa-line-chart"></i> ${_('Recent Activity')}</h3>
89
+                                    </div>
90
+                                    <div class="panel-body">
91
+                                        % if fake_api.last_actives.nb <= 0:
92
+                                            ${P.EMPTY_CONTENT(_('There\'s no activity yet.'))}
93
+                                        % else:
94
+                                            <table class="table table-hover">
95
+                                                % for item in fake_api.last_actives.contents:
96
+                                                    <tr>
97
+                                                        <td>
98
+                                                            <i class="${item.type.icon} fa-fw ${item.type.color}"></i>
99
+                                                            <a href="${item.url}">${item.label}</a>
100
+                                                            <br/>
101
+                                                            <span class="t-less-visible">${item.workspace.label}</span>
102
+                                                        </td>
103
+                                                        <td title="${_('Last activity: {datetime}').format(datetime=item.last_activity.label)}">
104
+                                                            ${item.last_activity.delta}
105
+                                                        </td>
106
+                                                    </tr>
107
+                                                % endfor
108
+                                            </table>
109
+                                        % endif
110
+                                    </div>
60
                                 </div>
111
                                 </div>
61
-                                % if fake_api.last_actives.nb <= 0:
62
-                                    ${P.EMPTY_CONTENT(_('There\'s no activity yet.'))}
63
-                                % else:
64
-                                    <table class="table table-hover">
65
-                                        % for item in fake_api.last_actives.contents:
66
-                                            <tr>
67
-                                                <td>
68
-                                                    <i class="${item.type.icon} fa-fw ${item.type.color}"></i>
69
-                                                    <a href="${item.url}">${item.label}</a>
70
-                                                    <br/>
71
-                                                    <span class="t-less-visible">${item.workspace.label}</span>
72
-                                                </td>
73
-                                                <td title="${_('Last activity: {datetime}').format(datetime=item.last_activity.label)}">
74
-                                                    ${item.last_activity.delta}
75
-                                                </td>
76
-                                            </tr>
77
-                                        % endfor
78
-                                    </table>
79
-                                % endif
80
                             </div>
112
                             </div>
81
                         </div>
113
                         </div>
82
-
83
-##                        <div class="col-sm-6">
84
-##                            <div class="panel panel-default">
85
-##                                <div class="panel-heading">
86
-##                                    <h3 class="panel-title"><i class="fa fa-thumbs-down"></i> ${_('Still open after...')}</h3>
87
-##                                </div>
88
-##                                % if fake_api.oldest_opens.nb <= 0:
89
-##                                    ${P.EMPTY_CONTENT(_('Nothing to close.'))}
90
-##                                % else:
91
-##                                    <table class="table table-hover">
92
-##                                        % for item in fake_api.oldest_opens.contents:
93
-##                                            <tr>
94
-##                                                <td>
95
-##                                                    <i class="${item.type.icon} fa-fw ${item.type.color}"></i>
96
-##                                                    <a href="${item.url}">${item.label}</a>
97
-##                                                </td>
98
-##                                                <td title="${_('Last activity: {datetime}').format(datetime=item.last_activity.label)}">
99
-##                                                    ${item.last_activity.delta}
100
-##                                                </td>
101
-##                                            </tr>
102
-##                                        % endfor
103
-##                                    </table>
104
-##                                % endif
105
-##                            </div>
106
-##                        </div>
107
-
108
-
109
-##                        <div class="col-sm-6">
110
-##                            <div class="panel panel-default">
111
-##                                <div class="panel-heading">
112
-##                                    <h3 class="panel-title"><i class="fa fa-star"></i> ${_('Favorites')}</h3>
113
-##                                </div>
114
-##
115
-##                                last_active_contents
116
-##
117
-##                                    % if fake_api.favorites.nb <= 0:
118
-##                                        ${P.EMPTY_CONTENT(_('You did not set any favorite yet.'))}
119
-##                                    % else:
120
-##                                        <table class="table table-hover">
121
-##                                            % for item in fake_api.favorites.contents:
122
-##                                                <tr>
123
-##                                                    <td>
124
-##                                                        <i class="${item.type.icon} fa-fw ${item.type.color}"></i>
125
-##                                                        <a href="${item.url}">${item.label}</a>
126
-##                                                    </td>
127
-##                                                    <td class="text-right">
128
-##                                                        <i class="${item.status.icon} fa-fw ${item.status.css}" title="${item.status.label}"></i>
129
-##                                                    </td>
130
-##                                                </tr>
131
-##                                            % endfor
132
-##                                        </table>
133
-##                                    % endif
134
-####                                </div>
135
-##                            </div>
136
-##                        </div>
137
                     </div>
114
                     </div>
115
+                </div>
116
+            </div>
138
 
117
 
139
-                    ## Workspace list and notifications
140
-                    <div class="row">
118
+            ## Workspace list and notifications
119
+            <div class="row t-half-spacer-above" id="workspaces-panel">
120
+                <div class="col-md-offset-3 col-sm-7">
121
+                    <div class="row t-spacer-above">
141
                         <div class="col-sm-12">
122
                         <div class="col-sm-12">
142
-                            <div class="panel panel-default">
123
+                            <div class="panel panel-info">
143
                                 <div class="panel-heading">
124
                                 <div class="panel-heading">
144
                                     <h3 class="panel-title"><i class="fa fa-bank"></i> ${_('Your workspaces')}</h3>
125
                                     <h3 class="panel-title"><i class="fa fa-bank"></i> ${_('Your workspaces')}</h3>
145
                                 </div>
126
                                 </div>

+ 6 - 0
tracim/tracim/templates/page/toolbar.mak 查看文件

1
 <%namespace name="TIM" file="tracim.templates.pod"/>
1
 <%namespace name="TIM" file="tracim.templates.pod"/>
2
+<%namespace name="BUTTON" file="tracim.templates.widgets.button"/>
2
 
3
 
3
 <%def name="SECURED_PAGE(user, workspace, page)">
4
 <%def name="SECURED_PAGE(user, workspace, page)">
5
+    <div class="btn-group btn-group-vertical">
6
+        ${BUTTON.MARK_CONTENT_READ_OR_UNREAD(user, workspace, page)}
7
+    </div>
8
+    <p></p>
9
+
4
     <% edit_disabled = ('', 'disabled')[page.selected_revision!='latest' or page.status.id[:6]=='closed'] %>
10
     <% edit_disabled = ('', 'disabled')[page.selected_revision!='latest' or page.status.id[:6]=='closed'] %>
5
     <% delete_or_archive_disabled = ('', 'disabled')[page.selected_revision!='latest'] %> 
11
     <% delete_or_archive_disabled = ('', 'disabled')[page.selected_revision!='latest'] %> 
6
     % if h.user_role(user, workspace)>1:
12
     % if h.user_role(user, workspace)>1:

+ 6 - 0
tracim/tracim/templates/thread/toolbar.mak 查看文件

1
 <%namespace name="TIM" file="tracim.templates.pod"/>
1
 <%namespace name="TIM" file="tracim.templates.pod"/>
2
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
2
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
3
+<%namespace name="BUTTON" file="tracim.templates.widgets.button"/>
3
 
4
 
4
 <%def name="SECURED_THREAD(user, workspace, thread)">
5
 <%def name="SECURED_THREAD(user, workspace, thread)">
6
+    <div class="btn-group btn-group-vertical">
7
+        ${BUTTON.MARK_CONTENT_READ_OR_UNREAD(user, workspace, thread)}
8
+    </div>
9
+    <p></p>
10
+
5
     <% edit_disabled = ('', 'disabled')[thread.selected_revision!='latest' or thread.status.id[:6]=='closed'] %>
11
     <% edit_disabled = ('', 'disabled')[thread.selected_revision!='latest' or thread.status.id[:6]=='closed'] %>
6
     <% delete_or_archive_disabled = ('', 'disabled')[thread.selected_revision!='latest'] %> 
12
     <% delete_or_archive_disabled = ('', 'disabled')[thread.selected_revision!='latest'] %> 
7
     % if h.user_role(user, workspace)>1:
13
     % if h.user_role(user, workspace)>1:

+ 15 - 1
tracim/tracim/templates/user_toolbars.mak 查看文件

39
                 user_edit_url = tg.url('/user/{}/edit'.format(current_user.id), {'next_url': '/home'})
39
                 user_edit_url = tg.url('/user/{}/edit'.format(current_user.id), {'next_url': '/home'})
40
                 user_password_edit_url = tg.url('/user/{}/password/edit'.format(current_user.id))
40
                 user_password_edit_url = tg.url('/user/{}/password/edit'.format(current_user.id))
41
             %>
41
             %>
42
-        <a title="${_('Edit my profile')}" class="btn btn-default" data-toggle="modal" data-target="#user-edit-modal-dialog" data-remote="${user_edit_url}" >${ICON.FA('fa-edit t-less-visible')} ${_('Edit my profile')}</a>
42
+            <a title="${_('Edit my profile')}" class="btn btn-default" data-toggle="modal" data-target="#user-edit-modal-dialog" data-remote="${user_edit_url}" >${ICON.FA('fa-edit t-less-visible')} ${_('Edit my profile')}</a>
43
             <a title="${_('Change password')}" class="btn btn-default" data-toggle="modal" data-target="#user-edit-password-modal-dialog" data-remote="${user_password_edit_url}" >${ICON.FA('fa-key t-less-visible')} ${_('Password')}</a>
43
             <a title="${_('Change password')}" class="btn btn-default" data-toggle="modal" data-target="#user-edit-password-modal-dialog" data-remote="${user_password_edit_url}" >${ICON.FA('fa-key t-less-visible')} ${_('Password')}</a>
44
         </div>
44
         </div>
45
         <p></p>
45
         <p></p>
46
+        <h3 class="t-spacer-above" style="margin-top: 1em;">
47
+            <i class="fa fa-flash"></i> ${_('Go to...')}
48
+        </h3>
49
+
50
+        <div class="btn-group btn-group-vertical">
51
+            <%
52
+                user_edit_url = tg.url('/user/{}/edit'.format(current_user.id), {'next_url': '/home'})
53
+                user_password_edit_url = tg.url('/user/{}/password/edit'.format(current_user.id))
54
+            %>
55
+            <a title="${_('Unread')}" class="btn btn-default" href="#unread-content-panel" >${ICON.FA('fa-fw fa-eye-slash t-less-visible')} ${_('Unread')}</a>
56
+            <a title="${_('Recent Activity')}" class="btn btn-default" href="#recent-activity-panel" >${ICON.FA('fa-fw fa-line-chart t-less-visible')} ${_('Activity')}</a>
57
+            <a title="${_('My Workspaces')}" class="btn btn-default" href="#workspaces-panel" >${ICON.FA('fa-fw fa-bank t-less-visible')} ${_('Spaces')}</a>
58
+        </div>
59
+        <p></p>
46
     </div> <!-- # End of side bar right -->
60
     </div> <!-- # End of side bar right -->
47
     ## SIDEBAR RIGHT [END]
61
     ## SIDEBAR RIGHT [END]
48
 </%def>
62
 </%def>

+ 8 - 3
tracim/tracim/templates/user_workspace_widgets.mak 查看文件

336
 </%def>
336
 </%def>
337
 
337
 
338
 <%def name="SECURED_HISTORY_VIRTUAL_EVENT(user, event)">
338
 <%def name="SECURED_HISTORY_VIRTUAL_EVENT(user, event)">
339
-    <div class="row t-odd-or-even t-hacky-thread-comment-border-top">
339
+    <% is_new_css_class = 't-is-new-content' if event.is_new else '' %>
340
+
341
+    <div class="row t-odd-or-even t-hacky-thread-comment-border-top ${is_new_css_class}">
340
         <div class="col-sm-7 col-sm-offset-3">
342
         <div class="col-sm-7 col-sm-offset-3">
341
             <div class="t-timeline-item">
343
             <div class="t-timeline-item">
342
 ##                <i class="fa fa-fw fa-3x fa-comment-o t-less-visible" style="margin-left: -1.5em; float:left;"></i>
344
 ##                <i class="fa fa-fw fa-3x fa-comment-o t-less-visible" style="margin-left: -1.5em; float:left;"></i>
372
 </%def>
374
 </%def>
373
 
375
 
374
 <%def name="SECURED_HISTORY_VIRTUAL_EVENT_AS_TABLE_ROW(user, event, current_revision_id)">
376
 <%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
+    <%
378
+        warning_or_not = ('', 'warning')[current_revision_id==event.id]
379
+        row_css = 't-is-new-content' if event.is_new else warning_or_not
380
+    %>
381
+    <tr class="${row_css}">
377
         <td class="t-less-visible">
382
         <td class="t-less-visible">
378
             <span class="label label-default">${ICON.FA_FW(event.type.icon)} ${event.type.label}</span>
383
             <span class="label label-default">${ICON.FA_FW(event.type.icon)} ${event.type.label}</span>
379
         </td>
384
         </td>

+ 19 - 0
tracim/tracim/templates/widgets/button.mak 查看文件

34
         % endif
34
         % endif
35
     </div>
35
     </div>
36
 </%def>
36
 </%def>
37
+
38
+<%def name="MARK_CONTENT_READ_OR_UNREAD(current_user, workspace, content)">
39
+    <%
40
+        disabled_or_not = ''
41
+        if 'latest' != content.selected_revision:
42
+            disabled_or_not = 'disabled'
43
+    %>
44
+    % if content.is_new:
45
+       <a href="${content.urls.mark_read}" class="btn btn-success ${disabled_or_not}" style="text-align: center;">
46
+           <i class="fa fa-4x fa-fw fa-eye"></i><br/>
47
+           <span style="color: #FFF">${_('Mark read')}</span>
48
+       </a>
49
+    % else:
50
+       <a href="${content.urls.mark_unread}" class="btn btn-default ${disabled_or_not}" style="text-align: center;">
51
+           <i class="fa fa-4x fa-fw fa-eye-slash tracim-less-visible"></i><br/>
52
+           <span class="tracim-less-visible">${_('Mark unread')}</span>
53
+       </a>
54
+    % endif
55
+</%def>