Browse Source

Merge pull request #24 from lebouquetin/feature/read-not-read-content

Tracim 10 years ago
parent
commit
6fd2e5e86a

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

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
 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 View File

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 View File

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 View File

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()

+ 7 - 4
tracim/tracim/lib/user.py View File

68
 
68
 
69
     @classmethod
69
     @classmethod
70
     def get_current_user(cls) -> User:
70
     def get_current_user(cls) -> User:
71
-        identity = tg.request.identity
72
-
73
-        if tg.request.identity:
74
-            return cls._get_user(tg.request.identity['repoze.who.userid'])
71
+        # HACK - D.A. - 2015-09-02
72
+        # In tests, the tg.request.identity may not be set
73
+        # (this is a buggy case, but for now this is how the software is;)
74
+        if tg.request != None:
75
+            if hasattr(tg.request, 'identity'):
76
+                if tg.request.identity != None:
77
+                    return cls._get_user(tg.request.identity['repoze.who.userid'])
75
 
78
 
76
         return None
79
         return None
77
 
80
 

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

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:
490
     def created_as_delta(self, delta_from_datetime:datetime=None):
494
     def created_as_delta(self, delta_from_datetime:datetime=None):
491
         if not delta_from_datetime:
495
         if not delta_from_datetime:
492
             delta_from_datetime = datetime.now()
496
             delta_from_datetime = datetime.now()
497
+
493
         return format_timedelta(delta_from_datetime - self.created,
498
         return format_timedelta(delta_from_datetime - self.created,
494
                                 locale=tg.i18n.get_lang()[0])
499
                                 locale=tg.i18n.get_lang()[0])
495
 
500
 
556
                 last_revision_date = child.updated
561
                 last_revision_date = child.updated
557
         return last_revision_date
562
         return last_revision_date
558
 
563
 
564
+    def has_new_information_for(self, user: User) -> bool:
565
+        """
566
+        :param user: the session current user
567
+        :return: bool, True if there is new information for given user else False
568
+                       False if the user is None
569
+        """
570
+        revision = self.get_current_revision()
571
+
572
+        if not user:
573
+            return False
574
+
575
+        if user not in revision.read_by.keys():
576
+            # The user did not read this item, so yes!
577
+            return True
578
+
579
+        for child in self.get_valid_children():
580
+            if child.has_new_information_for(user):
581
+                return True
582
+
583
+        return False
584
+
559
     def get_comments(self):
585
     def get_comments(self):
560
         children = []
586
         children = []
561
         for child in self.children:
587
         for child in self.children:
589
 
615
 
590
         return None
616
         return None
591
 
617
 
618
+    def get_current_revision(self) -> 'ContentRevisionRO':
619
+        # TODO - D.A. - 2015-08-26
620
+        # This code is not efficient at all!!!
621
+        # We should get the revision id directly from the view
622
+        rev_ids = [revision.revision_id for revision in self.revisions]
623
+        rev_ids.sort()
624
+
625
+        for revision in self.revisions:
626
+            if revision.revision_id == rev_ids[-1]:
627
+                return revision
628
+
629
+        return None
630
+
592
     def description_as_raw_text(self):
631
     def description_as_raw_text(self):
593
         # 'html.parser' fixes a hanging bug
632
         # 'html.parser' fixes a hanging bug
594
         # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
633
         # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
619
                                key=lambda event: event.created, reverse=True)
658
                                key=lambda event: event.created, reverse=True)
620
         return sorted_events
659
         return sorted_events
621
 
660
 
661
+    @classmethod
662
+    def format_path(cls, url_template: str, content: 'Content') -> str:
663
+        wid = content.workspace.workspace_id
664
+        fid = content.parent_id  # May be None if no parent
665
+        ctype = content.type
666
+        cid = content.content_id
667
+        return url_template.format(wid=wid, fid=fid, ctype=ctype, cid=cid)
668
+
622
 
669
 
623
 class ContentChecker(object):
670
 class ContentChecker(object):
624
 
671
 
693
     def get_last_action(self) -> ActionDescription:
740
     def get_last_action(self) -> ActionDescription:
694
         return ActionDescription(self.revision_type)
741
         return ActionDescription(self.revision_type)
695
 
742
 
743
+    # Read by must be used like this:
744
+    # read_datetime = revision.ready_by[<User instance>]
745
+    # if user did not read the content, then a key error is raised
746
+    read_by = association_proxy(
747
+        'revision_read_statuses',  # name of the attribute
748
+        'view_datetime',  # attribute the value is taken from
749
+        creator=lambda k, v: \
750
+            RevisionReadStatus(user=k, view_datetime=v)
751
+    )
752
+
753
+    def has_new_information_for(self, user: User) -> bool:
754
+        """
755
+        :param user: the session current user
756
+        :return: bool, True if there is new information for given user else False
757
+                       False if the user is None
758
+        """
759
+        if not user:
760
+            return False
761
+
762
+        if user not in self.read_by.keys():
763
+            return True
764
+
765
+        return False
766
+
767
+class RevisionReadStatus(DeclarativeBase):
768
+
769
+    __tablename__ = 'revision_read_status'
770
+
771
+    revision_id = Column(Integer, ForeignKey('content_revisions.revision_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
772
+    user_id = Column(Integer, ForeignKey('users.user_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
773
+    view_datetime = Column(DateTime, unique=False, nullable=False, server_default=func.now())
774
+
775
+    # content_revision = relationship(
776
+    #     'ContentRevisionRO',
777
+    #     remote_side=[ContentRevisionRO.revision_id],
778
+    #     backref='revision_read_statuses')
779
+
780
+    content_revision = relationship(
781
+        'ContentRevisionRO',
782
+        backref=backref(
783
+            'revision_read_statuses',
784
+            collection_class=attribute_mapped_collection('user'),
785
+            cascade='all, delete-orphan'
786
+        ))
787
+
788
+    user = relationship('User', backref=backref(
789
+        'revision_readers',
790
+        collection_class=attribute_mapped_collection('view_datetime'),
791
+        cascade='all, delete-orphan'
792
+    ))
793
+
696
 
794
 
697
 class NodeTreeItem(object):
795
 class NodeTreeItem(object):
698
     """
796
     """

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

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 View File

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 View File

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 View File

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 View File

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 View File

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 View File

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 View File

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 View File

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>

+ 8 - 1
tracim/tracim/tests/__init__.py View File

7
 from webtest import TestApp
7
 from webtest import TestApp
8
 
8
 
9
 from gearbox.commands.setup_app import SetupAppCommand
9
 from gearbox.commands.setup_app import SetupAppCommand
10
+
11
+import tg
10
 from tg import config
12
 from tg import config
11
 from tg.util import Bunch
13
 from tg.util import Bunch
12
 
14
 
128
 
130
 
129
 class TestStandard(object):
131
 class TestStandard(object):
130
 
132
 
133
+    application_under_test = application_name
134
+
131
     def setUp(self):
135
     def setUp(self):
132
-        self.app = load_app('main')
136
+        self.app = load_app(self.application_under_test)
133
 
137
 
134
         logger.debug(self, 'Start setUp() by trying to clean database...')
138
         logger.debug(self, 'Start setUp() by trying to clean database...')
135
         try:
139
         try:
146
         setup_db()
150
         setup_db()
147
         logger.debug(self, 'Start Database Setup... -> done')
151
         logger.debug(self, 'Start Database Setup... -> done')
148
 
152
 
153
+        self.app.get('/_test_vars')  # Allow to create fake context
154
+        tg.i18n.set_lang('en')  # Set a default lang
155
+
149
     def tearDown(self):
156
     def tearDown(self):
150
         transaction.commit()
157
         transaction.commit()
151
 
158
 

+ 46 - 55
tracim/tracim/tests/library/test_serializers.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 
2
 
3
+from datetime import datetime
3
 from nose.tools import eq_
4
 from nose.tools import eq_
4
 from nose.tools import ok_
5
 from nose.tools import ok_
5
 from nose.tools import raises
6
 from nose.tools import raises
7
 from sqlalchemy.orm.exc import NoResultFound
8
 from sqlalchemy.orm.exc import NoResultFound
8
 
9
 
9
 import transaction
10
 import transaction
11
+import tg
10
 
12
 
11
 from tracim.model import DBSession
13
 from tracim.model import DBSession
12
 
14
 
73
         eq_(3, len(res.keys()))
75
         eq_(3, len(res.keys()))
74
 
76
 
75
     def test_serialize_Content_DEFAULT(self):
77
     def test_serialize_Content_DEFAULT(self):
78
+        self.app.get('/_test_vars')  # Allow to create fake context
79
+
76
         obj = Content()
80
         obj = Content()
77
         obj.content_id = 132
81
         obj.content_id = 132
78
         obj.label = 'Some label'
82
         obj.label = 'Some label'
92
         eq_(None, res.workspace, res)
96
         eq_(None, res.workspace, res)
93
         eq_(4, len(res.keys()), res)
97
         eq_(4, len(res.keys()), res)
94
 
98
 
99
+    def test_serialize_Content_comment_THREAD(self):
100
+        wor = Workspace()
101
+        wor.workspace_id = 4
95
 
102
 
103
+        fol = Content()
104
+        fol.type = ContentType.Folder
105
+        fol.content_id = 72
106
+        fol.workspace = wor
96
 
107
 
97
-    # def test_serialize_Content_comment_THREAD(self):
98
-    #
99
-    #     wor = Workspace()
100
-    #     wor.workspace_id = 4
101
-    #
102
-    #     fol = Content()
103
-    #     fol.type = ContentType.Folder
104
-    #     fol.content_id = 72
105
-    #     fol.workspace = wor
106
-    #
107
-    #     par = Content()
108
-    #     par.type = ContentType.Thread
109
-    #     par.content_id = 37
110
-    #     par.parent = fol
111
-    #     par.workspace = wor
112
-    #
113
-    #     obj = Content()
114
-    #     obj.type = ContentType.Comment
115
-    #     obj.content_id = 132
116
-    #     obj.label = 'some label'
117
-    #     obj.description = 'Some Description'
118
-    #     obj.parent = par
119
-    #
120
-    #     res = Context(CTX.THREAD).toDict(obj)
121
-    #     eq_(res.__class__, DictLikeClass, res)
122
-    #
123
-    #     ok_('label' in res.keys())
124
-    #     eq_(obj.label, res.label, res)
125
-    #
126
-    #     ok_('content' in res.keys())
127
-    #     eq_(obj.description, res.content, res)
128
-    #
129
-    #     ok_('created' in res.keys())
130
-    #
131
-    #     ok_('icon' in res.keys())
132
-    #     eq_(ContentType.icon(obj.type), res.icon, res)
133
-    #
134
-    #     ok_('id' in res.folder.keys())
135
-    #     eq_(obj.content_id, res.id, res)
136
-    #
137
-    #     ok_('owner' in res.folder.keys())
138
-    #     eq_(None, res.owner, res) # TODO - test with a owner value
139
-    #
140
-    #     ok_('type' in res.folder.keys())
141
-    #     eq_(obj.type, res.type, res)
142
-    #
143
-    #     ok_('urls' in res.folder.keys())
144
-    #     ok_('delete' in res.urls.keys())
145
-    #
146
-    #     eq_(8, len(res.keys()), res)
108
+        par = Content()
109
+        par.type = ContentType.Thread
110
+        par.content_id = 37
111
+        par.parent = fol
112
+        par.workspace = wor
113
+        par.created = datetime.now()
147
 
114
 
148
-    def test_serializer_get_converter_return_CTX_DEFAULT(self):
115
+        obj = Content()
116
+        obj.type = ContentType.Comment
117
+        obj.content_id = 132
118
+        obj.label = 'some label'
119
+        obj.description = 'Some Description'
120
+        obj.parent = par
121
+        obj.created = datetime.now()
122
+
123
+        print('LANGUAGES #2 ARE', tg.i18n.get_lang())
124
+        res = Context(CTX.THREAD).toDict(obj)
125
+        eq_(res.__class__, DictLikeClass, res)
126
+
127
+        ok_('label' in res.keys())
128
+        eq_(obj.label, res.label, res)
129
+
130
+        ok_('content' in res.keys())
131
+        eq_(obj.description, res.content, res)
149
 
132
 
133
+        ok_('created' in res.keys())
134
+
135
+        ok_('icon' in res.keys())
136
+        eq_(ContentType.get_icon(obj.type), res.icon, res)
137
+
138
+        ok_('delete' in res.urls.keys())
139
+
140
+        eq_(10, len(res.keys()), len(res.keys()))
141
+
142
+    def test_serializer_get_converter_return_CTX_DEFAULT(self):
150
         class A(object):
143
         class A(object):
151
             pass
144
             pass
152
 
145
 
168
 
161
 
169
         converter = Context.get_converter(CTX.FILE, A)
162
         converter = Context.get_converter(CTX.FILE, A)
170
 
163
 
171
-    def test_serializer_toDict_int_str_and_LazyString(self):
172
 
164
 
165
+
166
+    def test_serializer_toDict_int_str_and_LazyString(self):
173
         s = Context(CTX.DEFAULT).toDict(5)
167
         s = Context(CTX.DEFAULT).toDict(5)
174
         ok_(isinstance(s, int))
168
         ok_(isinstance(s, int))
175
         eq_(5, s)
169
         eq_(5, s)
184
         eq_(lazystr, s3)
178
         eq_(lazystr, s3)
185
 
179
 
186
     def test_serializer_toDict_for_list_of_objects(self):
180
     def test_serializer_toDict_for_list_of_objects(self):
187
-
188
         class A(object):
181
         class A(object):
189
             def __init__(self, name):
182
             def __init__(self, name):
190
                 self.name = name
183
                 self.name = name
226
 
219
 
227
 
220
 
228
     def test_serializer_content__menui_api_context__children(self):
221
     def test_serializer_content__menui_api_context__children(self):
229
-        self.app.get('/_test_vars')  # Allow to create fake context
230
-
231
         folder_without_child = Content()
222
         folder_without_child = Content()
232
         folder_without_child.type = ContentType.Folder
223
         folder_without_child.type = ContentType.Folder
233
         res = Context(CTX.MENU_API).toDict(folder_without_child)
224
         res = Context(CTX.MENU_API).toDict(folder_without_child)