Browse Source

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

Damien ACCORSI 9 years ago
parent
commit
60d9d5870e

+ 28 - 0
tracim/migration/versions/43a323cc661_add_read_not_read_content_status.py View File

@@ -0,0 +1,28 @@
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 View File

@@ -6,6 +6,7 @@ import tg
6 6
 from tg import RestController
7 7
 from tg import tmpl_context
8 8
 from tg.i18n import ugettext as _
9
+from tg.predicates import not_anonymous
9 10
 
10 11
 from tracim.lib import CST
11 12
 from tracim.lib.base import BaseController
@@ -341,7 +342,6 @@ class TIMWorkspaceContentRestController(TIMRestControllerWithBreadcrumb):
341 342
             tg.flash(msg, CST.STATUS_ERROR)
342 343
             tg.redirect(next_url)
343 344
 
344
-
345 345
     @tg.require(current_user_is_content_manager())
346 346
     @tg.expose()
347 347
     def put_archive_undo(self, item_id):
@@ -415,6 +415,49 @@ class TIMWorkspaceContentRestController(TIMRestControllerWithBreadcrumb):
415 415
             tg.flash(msg, CST.STATUS_ERROR)
416 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 462
 class StandardController(BaseController):
420 463
 

+ 8 - 4
tracim/tracim/controllers/content.py View File

@@ -172,7 +172,8 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
172 172
         workspace = tmpl_context.workspace
173 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 177
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
177 178
 
178 179
         content_api = ContentApi(user)
@@ -183,9 +184,11 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
183 184
 
184 185
         fake_api_breadcrumb = self.get_breadcrumb(file_id)
185 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 192
         return DictLikeClass(result = dictified_file, fake_api=fake_api)
190 193
 
191 194
     @tg.require(current_user_is_reader())
@@ -670,7 +673,8 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
670 673
         workspace = tmpl_context.workspace
671 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 678
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
675 679
 
676 680
         content_api = ContentApi(user)

+ 3 - 0
tracim/tracim/controllers/root.py View File

@@ -129,6 +129,9 @@ class RootController(StandardController):
129 129
         last_active_contents = ContentApi(user).get_last_active(None, ContentType.Any, None)
130 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 135
         # INFO - D.A. - 2015-05-20
133 136
         # For now, we do not have favorties and read/unread status
134 137
         # so we only show:

+ 129 - 5
tracim/tracim/lib/content.py View File

@@ -2,6 +2,7 @@
2 2
 
3 3
 __author__ = 'damien'
4 4
 
5
+import datetime
5 6
 import re
6 7
 
7 8
 import tg
@@ -12,6 +13,7 @@ from sqlalchemy.orm import aliased
12 13
 from sqlalchemy.orm import joinedload
13 14
 from sqlalchemy.orm.attributes import get_history
14 15
 from sqlalchemy import desc
16
+from sqlalchemy import distinct
15 17
 from sqlalchemy import not_
16 18
 from sqlalchemy import or_
17 19
 from tracim.lib import cmp_to_key
@@ -26,8 +28,9 @@ from tracim.model.data import ContentRevisionRO
26 28
 from tracim.model.data import Content
27 29
 from tracim.model.data import ContentType
28 30
 from tracim.model.data import NodeTreeItem
29
-from tracim.model.data import Workspace
31
+from tracim.model.data import RevisionReadStatus
30 32
 from tracim.model.data import UserRoleInWorkspace
33
+from tracim.model.data import Workspace
31 34
 
32 35
 def compare_content_for_sorting_by_type_and_name(content1: Content,
33 36
                                                  content2: Content):
@@ -156,6 +159,32 @@ class ContentApi(object):
156 159
 
157 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 188
     def _hard_filtered_base_query(self, workspace: Workspace=None):
160 189
         """
161 190
         If set to True, then filterign on is_deleted and is_archived will also
@@ -303,7 +332,7 @@ class ContentApi(object):
303 332
 
304 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 336
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
308 337
         assert content_type is not None# DYN_REMOVE
309 338
         assert isinstance(content_type, str) # DYN_REMOVE
@@ -335,6 +364,56 @@ class ContentApi(object):
335 364
 
336 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 417
     def set_allowed_content(self, folder: Content, allowed_content_dict:dict):
339 418
         """
340 419
         :param folder: the given folder instance
@@ -350,7 +429,6 @@ class ContentApi(object):
350 429
         properties = dict(allowed_content = allowed_content_dict)
351 430
         folder.properties = properties
352 431
 
353
-
354 432
     def set_status(self, content: Content, new_status: str):
355 433
         if new_status in ContentStatus.allowed_values():
356 434
             content.status = new_status
@@ -358,7 +436,6 @@ class ContentApi(object):
358 436
         else:
359 437
             raise ValueError('The given value {} is not allowed'.format(new_status))
360 438
 
361
-
362 439
     def move(self, item: Content,
363 440
              new_parent: Content,
364 441
              must_stay_in_same_workspace:bool=True,
@@ -422,6 +499,54 @@ class ContentApi(object):
422 499
         content.is_deleted = False
423 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 550
     def flush(self):
426 551
         DBSession.flush()
427 552
 
@@ -443,7 +568,6 @@ class ContentApi(object):
443 568
         if action_description:
444 569
             content.revision_type = action_description
445 570
 
446
-
447 571
         if do_flush:
448 572
             DBSession.add(content)
449 573
             DBSession.flush()

+ 98 - 1
tracim/tracim/model/data.py View File

@@ -8,14 +8,18 @@ from bs4 import BeautifulSoup
8 8
 import datetime as datetime_root
9 9
 import json
10 10
 
11
+from sqlalchemy.ext.associationproxy import association_proxy
11 12
 from sqlalchemy import Column
13
+from sqlalchemy import func
12 14
 from sqlalchemy import ForeignKey
13 15
 from sqlalchemy import Sequence
14 16
 
15 17
 from sqlalchemy.ext.hybrid import hybrid_property
16 18
 
19
+from sqlalchemy.orm import backref
17 20
 from sqlalchemy.orm import relationship
18 21
 from sqlalchemy.orm import deferred
22
+from sqlalchemy.orm.collections import attribute_mapped_collection
19 23
 
20 24
 from sqlalchemy.types import Boolean
21 25
 from sqlalchemy.types import DateTime
@@ -468,7 +472,7 @@ class Content(DeclarativeBase):
468 472
     parent = relationship('Content', remote_side=[content_id], backref='children')
469 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 476
         for child in self.children:
473 477
             if not child.is_deleted and not child.is_archived:
474 478
                 if not content_types or child.type in content_types:
@@ -556,6 +560,27 @@ class Content(DeclarativeBase):
556 560
                 last_revision_date = child.updated
557 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 584
     def get_comments(self):
560 585
         children = []
561 586
         for child in self.children:
@@ -589,6 +614,19 @@ class Content(DeclarativeBase):
589 614
 
590 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 630
     def description_as_raw_text(self):
593 631
         # 'html.parser' fixes a hanging bug
594 632
         # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
@@ -619,6 +657,14 @@ class Content(DeclarativeBase):
619 657
                                key=lambda event: event.created, reverse=True)
620 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 669
 class ContentChecker(object):
624 670
 
@@ -693,6 +739,57 @@ class ContentRevisionRO(DeclarativeBase):
693 739
     def get_last_action(self) -> ActionDescription:
694 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 794
 class NodeTreeItem(object):
698 795
     """

+ 27 - 7
tracim/tracim/model/serializers.py View File

@@ -10,6 +10,7 @@ import tg
10 10
 from tg.i18n import ugettext as _
11 11
 from tg.util import LazyString
12 12
 from tracim.lib.base import logger
13
+from tracim.lib.user import UserStaticApi
13 14
 from tracim.lib.utils import exec_time_monitor
14 15
 from tracim.model.auth import Profile
15 16
 from tracim.model.auth import User
@@ -144,10 +145,14 @@ class Context(object):
144 145
 
145 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 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 156
         self._base_url = base_url # real root url like http://mydomain.com:8080
152 157
 
153 158
     def url(self, base_url='/', params=None, qualified=False) -> str:
@@ -157,6 +162,9 @@ class Context(object):
157 162
             url = '{}{}'.format(self._base_url, url)
158 163
         return  url
159 164
 
165
+    def get_user(self):
166
+        return self._current_user
167
+
160 168
     def toDict(self, serializableObject, key_value_for_a_list_object='', key_value_for_list_item_nb=''):
161 169
         """
162 170
         Converts a given object into a recursive dictionnary like structure.
@@ -351,6 +359,7 @@ def serialize_node_for_page_list(content: Content, context: Context):
351 359
 @pod_serializer(Content, CTX.PAGE)
352 360
 @pod_serializer(Content, CTX.FILE)
353 361
 def serialize_node_for_page(content: Content, context: Context):
362
+
354 363
     if content.type in (ContentType.Page, ContentType.File) :
355 364
         data_container = content
356 365
 
@@ -366,7 +375,7 @@ def serialize_node_for_page(content: Content, context: Context):
366 375
             parent=context.toDict(content.parent),
367 376
             workspace=context.toDict(content.workspace),
368 377
             type=content.type,
369
-
378
+            is_new=content.has_new_information_for(context.get_user()),
370 379
             content=data_container.description,
371 380
             created=data_container.created,
372 381
             label=data_container.label,
@@ -376,7 +385,11 @@ def serialize_node_for_page(content: Content, context: Context):
376 385
             links=context.toDict(content.extract_links_from_content(data_container.description)),
377 386
             revisions=context.toDict(sorted(content.revisions, key=lambda v: v.created, reverse=True)),
378 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 395
         if content.type==ContentType.File:
@@ -389,8 +402,9 @@ def serialize_node_for_page(content: Content, context: Context):
389 402
 
390 403
     if content.type==ContentType.Folder:
391 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 409
         return value
396 410
 
@@ -418,10 +432,10 @@ def serialize_content_for_history(event: VirtualEvent, context: Context):
418 432
         created=event.created,
419 433
         created_as_delta=event.created_as_delta(),
420 434
         content=event.content,
435
+        is_new=event.ref_object.has_new_information_for(context.get_user()),
421 436
         urls = urls
422 437
     )
423 438
 
424
-
425 439
 @pod_serializer(Content, CTX.THREAD)
426 440
 def serialize_node_for_page(item: Content, context: Context):
427 441
     if item.type==ContentType.Thread:
@@ -439,11 +453,17 @@ def serialize_node_for_page(item: Content, context: Context):
439 453
             type = item.type,
440 454
             workspace = context.toDict(item.workspace),
441 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 464
     if item.type==ContentType.Comment:
446 465
         return DictLikeClass(
466
+            is_new=item.has_new_information_for(context.get_user()),
447 467
             content = item.description,
448 468
             created = item.created,
449 469
             created_as_delta = item.created_as_delta(),

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

@@ -297,7 +297,7 @@ h1.page-header {
297 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 303
 .sidebar .list-group-item { background-color: transparent; }
@@ -354,3 +354,9 @@ h3 { background-color: #f5f5f5;}
354 354
 #t-full-app-alert-message-id > div.alert {
355 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 View File

@@ -1,7 +1,13 @@
1 1
 <%namespace name="TIM" file="tracim.templates.pod"/>
2 2
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
3
+<%namespace name="BUTTON" file="tracim.templates.widgets.button"/>
3 4
 
4 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 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 12
     <% edit_disabled = ('', 'disabled')[file.selected_revision!='latest' or file.status.id[:6]=='closed'] %>
7 13
     <% delete_or_archive_disabled = ('', 'disabled')[file.selected_revision!='latest'] %> 

+ 72 - 91
tracim/tracim/templates/home.mak View File

@@ -39,107 +39,88 @@
39 39
 <div class="container-fluid">
40 40
     <div class="row-fluid">
41 41
         <div>
42
-            <div class="row">
42
+            ## NOT READ
43
+            <div class="row" id="unread-content-panel">
43 44
                 <div class="col-md-offset-3 col-sm-7">
44 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 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 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 112
                             </div>
81 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 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 122
                         <div class="col-sm-12">
142
-                            <div class="panel panel-default">
123
+                            <div class="panel panel-info">
143 124
                                 <div class="panel-heading">
144 125
                                     <h3 class="panel-title"><i class="fa fa-bank"></i> ${_('Your workspaces')}</h3>
145 126
                                 </div>

+ 6 - 0
tracim/tracim/templates/page/toolbar.mak View File

@@ -1,6 +1,12 @@
1 1
 <%namespace name="TIM" file="tracim.templates.pod"/>
2
+<%namespace name="BUTTON" file="tracim.templates.widgets.button"/>
2 3
 
3 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 10
     <% edit_disabled = ('', 'disabled')[page.selected_revision!='latest' or page.status.id[:6]=='closed'] %>
5 11
     <% delete_or_archive_disabled = ('', 'disabled')[page.selected_revision!='latest'] %> 
6 12
     % if h.user_role(user, workspace)>1:

+ 6 - 0
tracim/tracim/templates/thread/toolbar.mak View File

@@ -1,7 +1,13 @@
1 1
 <%namespace name="TIM" file="tracim.templates.pod"/>
2 2
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
3
+<%namespace name="BUTTON" file="tracim.templates.widgets.button"/>
3 4
 
4 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 11
     <% edit_disabled = ('', 'disabled')[thread.selected_revision!='latest' or thread.status.id[:6]=='closed'] %>
6 12
     <% delete_or_archive_disabled = ('', 'disabled')[thread.selected_revision!='latest'] %> 
7 13
     % if h.user_role(user, workspace)>1:

+ 15 - 1
tracim/tracim/templates/user_toolbars.mak View File

@@ -39,10 +39,24 @@
39 39
                 user_edit_url = tg.url('/user/{}/edit'.format(current_user.id), {'next_url': '/home'})
40 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 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 44
         </div>
45 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 60
     </div> <!-- # End of side bar right -->
47 61
     ## SIDEBAR RIGHT [END]
48 62
 </%def>

+ 8 - 3
tracim/tracim/templates/user_workspace_widgets.mak View File

@@ -336,7 +336,9 @@
336 336
 </%def>
337 337
 
338 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 342
         <div class="col-sm-7 col-sm-offset-3">
341 343
             <div class="t-timeline-item">
342 344
 ##                <i class="fa fa-fw fa-3x fa-comment-o t-less-visible" style="margin-left: -1.5em; float:left;"></i>
@@ -372,8 +374,11 @@
372 374
 </%def>
373 375
 
374 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 382
         <td class="t-less-visible">
378 383
             <span class="label label-default">${ICON.FA_FW(event.type.icon)} ${event.type.label}</span>
379 384
         </td>

+ 19 - 0
tracim/tracim/templates/widgets/button.mak View File

@@ -34,3 +34,22 @@
34 34
         % endif
35 35
     </div>
36 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>