Added content reviews management
authorThierry Florac <thierry.florac@onf.fr>
Thu, 02 Jun 2016 15:31:37 +0200
changeset 35 7cdbe0f6e5c2
parent 34 f907592596bf
child 36 69a8e0ec5555
Added content reviews management
src/pyams_content/interfaces/review.py
src/pyams_content/shared/common/__init__.py
src/pyams_content/shared/common/review.py
src/pyams_content/shared/common/zmi/__init__.py
src/pyams_content/shared/common/zmi/review.py
src/pyams_content/shared/common/zmi/templates/review-add-comment.pt
src/pyams_content/shared/common/zmi/templates/review-comments-json.pt
src/pyams_content/shared/common/zmi/templates/review-comments.pt
src/pyams_content/shared/common/zmi/templates/review-notification.pt
src/pyams_content/skin/resources/css/pyams_content.css
src/pyams_content/skin/resources/css/pyams_content.min.css
src/pyams_content/skin/resources/js/pyams_content.js
src/pyams_content/skin/resources/js/pyams_content.min.js
src/pyams_content/skin/resources/less/pyams_content.less
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/interfaces/review.py	Thu Jun 02 15:31:37 2016 +0200
@@ -0,0 +1,74 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from zope.annotation.interfaces import IAttributeAnnotatable
+from zope.container.interfaces import IContainer, IContained
+
+# import packages
+from pyams_security.schema import Principal
+from zope.container.constraints import contains, containers
+from zope.interface import Interface
+from zope.schema import Text, Choice, Datetime
+
+from pyams_content import _
+
+
+COMMENT_TYPES = {'request': _("Review request"),
+                 'comment': _("Reviewer comment")}
+
+
+class IReviewComment(IContained, IAttributeAnnotatable):
+    """Review comment interface"""
+
+    containers('.IReviewComments')
+
+    owner = Principal(title=_("Comment writer"),
+                      required=True)
+
+    comment = Text(title=_("Comment body"),
+                   required=True)
+
+    comment_type = Choice(title=_("Comment type"),
+                          values=COMMENT_TYPES.keys(),
+                          required=True,
+                          default='comment')
+
+    creation_date = Datetime(title=_("Creation date"),
+                             required=False)
+
+
+REVIEW_COMMENTS_ANNOTATION_KEY = 'pyams_content.review_comments'
+
+
+class IReviewComments(IContainer):
+    """Review comments container interface"""
+
+    contains(IReviewComment)
+
+    def clear(self):
+        """Remove all comments"""
+
+    def add_comment(self, comment):
+        """Add given comment to list"""
+
+
+class IReviewManager(Interface):
+    """Content review interface"""
+
+    def ask_review(self, reviewers, comment, notify=True):
+        """Ask for content review"""
--- a/src/pyams_content/shared/common/__init__.py	Thu Jun 02 15:30:56 2016 +0200
+++ b/src/pyams_content/shared/common/__init__.py	Thu Jun 02 15:31:37 2016 +0200
@@ -17,8 +17,9 @@
 
 # import interfaces
 from hypatia.interfaces import ICatalog
+from pyams_content.interfaces import IBaseContentInfo
+from pyams_content.interfaces.review import IReviewComments
 from pyams_content.shared.common.interfaces import IWfSharedContent, IWfSharedContentRoles, ISharedContent, ISharedTool
-from pyams_content.interfaces import IBaseContentInfo
 from pyams_security.interfaces import IDefaultProtectionPolicy
 from pyams_sequence.interfaces import ISequentialIdTarget, ISequentialIdInfo
 from pyams_utils.interfaces import VIEW_PERMISSION
@@ -124,6 +125,8 @@
         content.contributors = contributors
     # reset modifiers
     content.modifiers = set()
+    # clear review comments
+    IReviewComments(content).clear()
 
 
 @adapter_config(context=IWfSharedContent, provides=ISequentialIdInfo)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/common/review.py	Thu Jun 02 15:31:37 2016 +0200
@@ -0,0 +1,169 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import os
+from datetime import datetime
+from uuid import uuid4
+
+# import interfaces
+from pyams_content.interfaces.review import IReviewManager, IReviewComment, IReviewComments, \
+    REVIEW_COMMENTS_ANNOTATION_KEY
+from pyams_content.shared.common.interfaces import IWfSharedContent, IWfSharedContentRoles
+from pyams_mail.interfaces import IPrincipalMailInfo
+from pyams_security.interfaces import ISecurityManager
+from pyams_security.interfaces.notification import INotificationSettings
+from pyramid_chameleon.interfaces import IChameleonTranslate
+from pyramid_mailer.interfaces import IMailer
+from zope.annotation.interfaces import IAnnotations
+from zope.lifecycleevent.interfaces import IObjectCreatedEvent
+from zope.location.interfaces import ISublocations
+from zope.traversing.interfaces import ITraversable
+
+# import packages
+from persistent import Persistent
+from pyams_mail.message import HTMLMessage
+from pyams_security.principal import MissingPrincipal
+from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyams_utils.container import BTreeOrderedContainer
+from pyams_utils.registry import query_utility
+from pyams_utils.request import check_request
+from pyramid.events import subscriber
+from pyramid_chameleon.zpt import PageTemplateFile
+from zope.container.contained import Contained
+from zope.interface import implementer
+from zope.location import locate
+from zope.schema.fieldproperty import FieldProperty
+
+from pyams_content import _
+
+
+@implementer(IReviewComment)
+class ReviewComment(Persistent, Contained):
+    """Review comment persistent class"""
+
+    owner = FieldProperty(IReviewComment['owner'])
+    comment = FieldProperty(IReviewComment['comment'])
+    comment_type = FieldProperty(IReviewComment['comment_type'])
+    creation_date = FieldProperty(IReviewComment['creation_date'])
+
+    def __init__(self, owner, comment, comment_type='comment'):
+        self.owner = owner
+        self.comment = comment
+        self.comment_type = comment_type
+        self.creation_date = datetime.utcnow()
+
+
+@implementer(IReviewComments)
+class ReviewCommentsContainer(BTreeOrderedContainer):
+    """Review comments container"""
+
+    def clear(self):
+        for k in self.keys()[:]:
+            del self[k]
+
+    def add_comment(self, comment):
+        uuid = str(uuid4())
+        self[uuid] = comment
+
+
+@adapter_config(context=IWfSharedContent, provides=IReviewComments)
+def SharedContentReviewCommentsFactory(context):
+    """Shared content review comments factory"""
+    annotations = IAnnotations(context)
+    comments = annotations.get(REVIEW_COMMENTS_ANNOTATION_KEY)
+    if comments is None:
+        comments = annotations[REVIEW_COMMENTS_ANNOTATION_KEY] = ReviewCommentsContainer()
+        locate(comments, context, '++review-comments++')
+    return comments
+
+
+@adapter_config(name='review-comments', context=IWfSharedContent, provides=ITraversable)
+class SharedContentReviewCommentsNamespace(ContextAdapter):
+    """++review-comments++ namespace traverser"""
+
+    def traverse(self, name, furtherpath=None):
+        return IReviewComments(self.context)
+
+
+@adapter_config(name='review-comments', context=IWfSharedContent, provides=ISublocations)
+class SharedContentReviewCommentsSublocations(ContextAdapter):
+    """Shared content review comments sub-location adapter"""
+
+    def sublocations(self):
+        return IReviewComments(self.context).values()
+
+
+@adapter_config(context=IWfSharedContent, provides=IReviewManager)
+class SharedContentReviewAdapter(ContextAdapter):
+    """Shared content review adapter"""
+
+    review_template = PageTemplateFile(os.path.join(os.path.dirname(__file__),
+                                                    'zmi/templates/review-notification.pt'))
+
+    def ask_review(self, reviewers, comment, notify_all=True):
+        """Ask for content review"""
+        roles = IWfSharedContentRoles(self.context, None)
+        if roles is None:
+            return
+        # check request
+        request = check_request()
+        translate = request.localizer.translate
+        # initialize mailer
+        security = query_utility(ISecurityManager)
+        settings = INotificationSettings(security)
+        sender_name = request.principal.title if request.principal is not None else settings.sender_name
+        sender_address = settings.sender_email
+        sender = security.get_principal(request.principal.id, info=False)
+        sender_mail_info = IPrincipalMailInfo(sender, None)
+        if sender_mail_info is not None:
+            for sender_name, sender_address in sender_mail_info.get_addresses():
+                break
+        mailer = query_utility(IMailer, name=settings.mailer)
+        # create message
+        message_body = self.review_template(request=request,
+                                            context=self.context,
+                                            translate=query_utility(IChameleonTranslate),
+                                            options={'settings': settings,
+                                                     'comment': comment,
+                                                     'sender': sender_name})
+        # notify reviewers
+        notifications = 0
+        readers = roles.readers
+        for reviewer in reviewers:
+            if settings.enable_notifications and (notify_all or (reviewer not in readers)):
+                principal = security.get_principal(reviewer, info=False)
+                if not isinstance(principal, MissingPrincipal):
+                    mail_info = IPrincipalMailInfo(principal, None)
+                    if mail_info is not None:
+                        for name, address in mail_info.get_addresses():
+                            message = HTMLMessage(
+                                subject=translate(_("[{service_name}] A content review is requested")).format(
+                                    service_name=settings.subject_prefix),
+                                fromaddr='{name} <{address}>'.format(name=sender_name,
+                                                                     address=sender_address),
+                                toaddr='{name} <{address}>'.format(name=name, address=address),
+                                html=message_body)
+                            mailer.send(message)
+                            notifications += 1
+            readers.add(reviewer)
+        roles.readers = readers
+        # add comment
+        review_comment = ReviewComment(owner=request.principal.id,
+                                       comment=comment,
+                                       comment_type='request')
+        IReviewComments(self.context).add_comment(review_comment)
+        # return notifications count
+        return notifications
--- a/src/pyams_content/shared/common/zmi/__init__.py	Thu Jun 02 15:30:56 2016 +0200
+++ b/src/pyams_content/shared/common/zmi/__init__.py	Thu Jun 02 15:31:37 2016 +0200
@@ -20,6 +20,7 @@
 # import interfaces
 from pyams_content.interfaces import MANAGE_SITE_ROOT_PERMISSION, MANAGE_CONTENT_PERMISSION, CREATE_CONTENT_PERMISSION, \
     PUBLISH_CONTENT_PERMISSION
+from pyams_content.interfaces.review import IReviewComments
 from pyams_content.shared.common.interfaces import IWfSharedContent, ISharedContent, ISharedTool, IManagerRestrictions
 from pyams_form.interfaces.form import IFormContextPermissionChecker, IWidgetsPrefixViewletsManager
 from pyams_i18n.interfaces import II18n, II18nManager
@@ -40,7 +41,7 @@
 from pyams_skin.page import DefaultPageHeaderAdapter
 from pyams_skin.table import DefaultElementEditorAdapter
 from pyams_skin.viewlet.breadcrumb import BreadcrumbItem
-from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem, ToolbarMenuDivider
 from pyams_template.template import template_config
 from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter, ContextAdapter, ContextRequestAdapter
 from pyams_utils.registry import get_utility
@@ -222,8 +223,14 @@
 # Duplication menus and views
 #
 
+@viewlet_config(name='duplication.divider', context=IWfSharedContent, layer=IPyAMSLayer,
+                view=Interface, manager=IContextActions, permission=CREATE_CONTENT_PERMISSION, weight=49)
+class WfSharedContentDuplicationMenuDivider(ToolbarMenuDivider):
+    """Shared content duplication menu divider"""
+
+
 @viewlet_config(name='duplication.menu', context=IWfSharedContent, layer=IPyAMSLayer,
-                view=Interface, manager=IContextActions, permission=CREATE_CONTENT_PERMISSION, weight=1)
+                view=Interface, manager=IContextActions, permission=CREATE_CONTENT_PERMISSION, weight=50)
 class WfSharedContentDuplicateMenu(ToolbarMenuItem):
     """Shared content duplication menu item"""
 
@@ -279,6 +286,7 @@
         new_version.creator = self.request.principal.id
         new_version.owner = self.request.principal.id
         new_version.modifiers = set()
+        IReviewComments(new_version).clear()
         # store new version
         translate = self.request.localizer.translate
         workflow = get_utility(IWorkflow, name=new_content.workflow_name)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/common/zmi/review.py	Thu Jun 02 15:31:37 2016 +0200
@@ -0,0 +1,264 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION, COMMENT_CONTENT_PERMISSION
+from pyams_content.interfaces.review import IReviewManager, IReviewComments
+from pyams_content.shared.common.interfaces import IWfSharedContent
+from pyams_security.interfaces import ISecurityManager
+from pyams_security.interfaces.profile import IPublicProfile
+from pyams_skin.interfaces.viewlet import IContextActions
+from pyams_skin.layer import IPyAMSLayer
+from pyams_zmi.interfaces.menu import IContentManagementMenu
+from pyams_zmi.layer import IAdminLayer
+from pyramid_chameleon.interfaces import IChameleonTranslate
+
+# import packages
+from pyams_content.shared.common.review import ReviewComment
+from pyams_form.form import AJAXAddForm
+from pyams_form.schema import CloseButton
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_security.schema import PrincipalsSet
+from pyams_skin.page import InnerPage
+from pyams_skin.viewlet.menu import MenuItem
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_template.template import template_config, get_view_template
+from pyams_utils.date import get_age
+from pyams_utils.registry import get_utility, query_utility
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogAddForm
+from pyramid.view import view_config
+from z3c.form import field, button
+from zope.interface import Interface
+from zope.schema import Text, Bool
+
+from pyams_content import _
+
+
+#
+# Review request form
+#
+
+@viewlet_config(name='ask-review.menu', context=IWfSharedContent, layer=IPyAMSLayer,
+                view=Interface, manager=IContextActions, permission=MANAGE_CONTENT_PERMISSION, weight=10)
+class WfSharedContentReviewMenu(ToolbarMenuItem):
+    """Shared content review menu"""
+
+    label = _("Ask for review...")
+    label_css_class = 'fa fa-fw fa-eye'
+
+    url = 'ask-review.html'
+    modal_target = True
+
+
+class ISharedContentReviewInfo(Interface):
+    """Shared content review infos"""
+
+    reviewers = PrincipalsSet(title=_("Sought principals"),
+                              description=_("List of principals from which a review is requested"),
+                              required=True)
+
+    comment = Text(title=_("Comment"),
+                   description=_("Comment associated with this request"),
+                   required=True)
+
+    notify_all = Bool(title=_("Notify all reviewers"),
+                      description=_("If 'yes', selected reviewers will be notified by mail of your request, "
+                                    "even if they were already members of the reviewers group. Otherwise, only new "
+                                    "reviewers will be notified"),
+                      default=False,
+                      required=True)
+
+
+class ISharedContentReviewButtons(Interface):
+    """Shared content review form buttons"""
+
+    close = CloseButton(name='close', title=_("Cancel"))
+    review = button.Button(name='review', title=_("Ask for content review"))
+
+
+@pagelet_config(name='ask-review.html', context=IWfSharedContent, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class WfSharedContentReviewForm(AdminDialogAddForm):
+    """Shared content review form"""
+
+    legend = _("Content review request")
+    icon_css_class = 'fa fa-fw fa-eye'
+
+    fields = field.Fields(ISharedContentReviewInfo)
+    buttons = button.Buttons(ISharedContentReviewButtons)
+
+    ajax_handler = 'ask-review.json'
+    edit_permission = MANAGE_CONTENT_PERMISSION
+
+    label_css_class = 'control-label col-md-4'
+    input_css_class = 'col-md-8'
+
+    def updateWidgets(self, prefix=None):
+        super(WfSharedContentReviewForm, self).updateWidgets(prefix)
+        self.widgets['comment'].label_css_class = 'textarea'
+        self.widgets['comment'].addClass('height-100')
+
+    def updateActions(self):
+        super(WfSharedContentReviewForm, self).updateActions()
+        if 'review' in self.actions:
+            self.actions['review'].addClass('btn-primary')
+
+    def createAndAdd(self, data):
+        manager = IReviewManager(self.context, None)
+        if manager is not None:
+            return manager.ask_review(data.get('reviewers'),
+                                      data.get('comment'),
+                                      data.get('notify_all'))
+
+
+@view_config(name='ask-review.json', context=IWfSharedContent, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class WfSharedContentReviewAJAXForm(AJAXAddForm, WfSharedContentReviewForm):
+    """Shared content review form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        translate = self.request.localizer.translate
+        if changes:
+            return {'status': 'success',
+                    'message': translate(_("Request successful. "
+                                           "{count} new notification(s) have been sent")).format(count=changes),
+                    'event': 'PyAMS_content.changed_item',
+                    'event_options': {'object_type': 'review_comments'}}
+        else:
+            return {'status': 'info',
+                    'message': translate(_("Request successful. No new notification have been sent")),
+                    'event': 'PyAMS_content.changed_item',
+                    'event_options': {'object_type': 'review_comments'}}
+
+
+#
+# Share contents comments
+#
+
+@viewlet_config(name='review-comments.menu', context=IWfSharedContent, layer=IAdminLayer,
+                manager=IContentManagementMenu, permission=COMMENT_CONTENT_PERMISSION, weight=30)
+class SharedContentReviewCommentsMenu(MenuItem):
+    """Shared content review comments menu"""
+
+    label = _("Comments")
+    icon_class = 'fa-comments-o'
+    url = '#review-comments.html'
+
+    badge_class = 'bg-color-info'
+
+    def update(self):
+        super(SharedContentReviewCommentsMenu, self).update()
+        nb_comments = len(IReviewComments(self.context))
+        self.badge = str(nb_comments)
+        if nb_comments == 0:
+            self.badge_class += ' hidden'
+
+
+@pagelet_config(name='review-comments.html', context=IWfSharedContent, layer=IPyAMSLayer,
+                permission=COMMENT_CONTENT_PERMISSION)
+@template_config(template='templates/review-comments.pt', layer=IPyAMSLayer)
+class SharedContentReviewCommentsView(InnerPage):
+    """Shared content review comments view"""
+
+    legend = _("Review comments")
+
+    comments = None
+    security = None
+
+    def update(self):
+        super(SharedContentReviewCommentsView, self).update()
+        self.comments = IReviewComments(self.context).values()
+        self.security = get_utility(ISecurityManager)
+
+    def get_principal(self, principal_id):
+        return self.security.get_principal(principal_id)
+
+    def get_avatar(self, principal):
+        return IPublicProfile(principal).avatar
+
+    def get_age(self, comment):
+        return get_age(comment.creation_date)
+
+
+@view_config(name='get-last-review-comments.json', context=IWfSharedContent, request_type=IPyAMSLayer,
+             permission=COMMENT_CONTENT_PERMISSION, renderer='json', xhr=True)
+@template_config(template='templates/review-comments-json.pt')
+class ReviewCommentsView(SharedContentReviewCommentsView):
+    """"Get review comments"""
+
+    def __init__(self, request):
+        self.request = request
+        self.context = request.context
+
+    template = get_view_template()
+
+    def __call__(self):
+        result = {'status': 'success',
+                  'count': 0}
+        comments = IReviewComments(self.context)
+        previous_count = int(self.request.params.get('count', 0))
+        current_count = len(comments)
+        if previous_count == current_count:
+            result['count'] = current_count
+        else:
+            self.comments = comments.values()[previous_count:]
+            self.security = get_utility(ISecurityManager)
+            comments_body = self.template(request=self.request,
+                                          context=self.context,
+                                          view=self,
+                                          translate=query_utility(IChameleonTranslate))
+            result.update({'content': comments_body,
+                           'count': len(comments)})
+        return result
+
+
+@view_config(name='add-review-comment.json', context=IWfSharedContent, request_type=IPyAMSLayer,
+             permission=COMMENT_CONTENT_PERMISSION, renderer='json', xhr=True)
+@template_config(template='templates/review-add-comment.pt')
+class ReviewCommentAddForm(object):
+    """Review comment add form"""
+
+    def __init__(self, request):
+        self.request = request
+        self.context = request.context
+
+    template = get_view_template()
+
+    def __call__(self):
+        request = self.request
+        translate = request.localizer.translate
+        comment_body = request.params.get('comment')
+        if not comment_body:
+            return {'status': 'error',
+                    'message': translate(_("Message is mandatory!"))}
+        # add new comment
+        comment = ReviewComment(owner=request.principal.id,
+                                comment=request.params.get('comment'))
+        comments = IReviewComments(request.context)
+        comments.add_comment(comment)
+        # return comment infos
+        profile = IPublicProfile(request.principal)
+        comment_body = self.template(request=request,
+                                     context=self.context,
+                                     translate=query_utility(IChameleonTranslate),
+                                     options={'comment': comment,
+                                              'profile': profile})
+        return {'status': 'success',
+                'callback': 'PyAMS_content.review.addCommentCallback',
+                'options': {'content': comment_body,
+                            'count': len(comments)}}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/common/zmi/templates/review-add-comment.pt	Thu Jun 02 15:31:37 2016 +0200
@@ -0,0 +1,16 @@
+<li class="message" tal:define="comment options['comment']" i18n:domain="pyams_content"
+	tal:attributes="class 'message {0}'.format(comment.comment_type)">
+	<tal:var define="avatar options['profile'].avatar">
+		<tal:if condition="avatar">
+			<img tal:attributes="src extension:absolute_url(avatar, '++thumb++square:32x32.png')" />
+		</tal:if>
+		<tal:if condition="not:avatar">
+			<i class="fa fa-lg fa-user img"></i>
+		</tal:if>
+		<span class="message-text">
+			<time i18n:translate="">just now</time>
+			<a class="username" tal:content="request.principal.title">Owner</a>
+			<tal:var content="structure extension:html(comment.comment)" />
+		</span>
+	</tal:var>
+</li>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/common/zmi/templates/review-comments-json.pt	Thu Jun 02 15:31:37 2016 +0200
@@ -0,0 +1,17 @@
+<li class="message" tal:repeat="comment view.comments" i18n:domain="pyams_content"
+	tal:attributes="class 'message {0} {1}'.format(comment.comment_type, 'odd' if repeat['comment'].odd else 'even')">
+	<tal:var define="principal view.get_principal(comment.owner);
+					 avatar view.get_avatar(principal);">
+		<tal:if condition="avatar">
+			<img tal:attributes="src extension:absolute_url(avatar, '++thumb++square:32x32.png')" />
+		</tal:if>
+		<tal:if condition="not:avatar">
+			<i class="fa fa-lg fa-user img"></i>
+		</tal:if>
+		<span class="message-text">
+			<time tal:content="view.get_age(comment)">age</time>
+			<a class="username" tal:content="principal.title">Owner</a>
+			<tal:var content="structure extension:html(comment.comment)" />
+		</span>
+	</tal:var>
+</li>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/common/zmi/templates/review-comments.pt	Thu Jun 02 15:31:37 2016 +0200
@@ -0,0 +1,54 @@
+<div class="ams-widget comments" i18n:domain="pyams_content"
+	 data-ams-plugins="pyams_content"
+	 data-ams-plugin-pyams_content-src="/--static--/pyams_content/js/pyams_content{MyAMS.devext}.js"
+	 data-ams-plugin-pyams_content-css="/--static--/pyams_content/css/pyams_content{MyAMS.devext}.css"
+	 data-ams-plugin-pyams_content-async="false"
+	 data-ams-callback="PyAMS_content.review.initComments">
+	<header>
+		<span tal:condition="view.widget_icon_class | nothing"
+			  class="widget-icon"><i tal:attributes="class view.widget_icon_class"></i>
+		</span>
+		<h2 tal:content="view.legend"></h2>
+		<tal:var content="structure provider:pyams.widget_title" />
+		<tal:var content="structure provider:pyams.toolbar" />
+	</header>
+	<div class="widget-body no-padding">
+		<div class="chat-body no-padding">
+			<ul class="messages">
+				<li class="message" tal:repeat="comment view.comments"
+					tal:attributes="class 'message {0} {1}'.format(comment.comment_type, 'odd' if repeat['comment'].odd else 'even')">
+					<tal:var define="principal view.get_principal(comment.owner);
+									 avatar view.get_avatar(principal);">
+						<tal:if condition="avatar">
+							<img tal:attributes="src extension:absolute_url(avatar, '++thumb++square:32x32.png')" />
+						</tal:if>
+						<tal:if condition="not:avatar">
+							<i class="fa fa-lg fa-user img"></i>
+						</tal:if>
+						<span class="message-text">
+							<time tal:content="view.get_age(comment)">age</time>
+							<a class="username" tal:content="principal.title">Owner</a>
+							<tal:var content="structure extension:html(comment.comment)" />
+						</span>
+					</tal:var>
+				</li>
+			</ul>
+		</div>
+		<div class="chat-footer no-padding">
+			<form method="post" data-async
+				  data-ams-form-data-init-callback="PyAMS_content.review.initCommentData"
+				  tal:attributes="data-ams-form-handler extension:absolute_url(context, 'add-review-comment.json')">
+				<fieldset class="textarea-div no-margin">
+					<div class="typearea">
+						<textarea placeholder="Add a comment..." name="comment"
+								  i18n:attributes="placeholder"></textarea>
+					</div>
+				</fieldset>
+				<span class="textarea-controls">
+					<button type="submit" class="btn btn-sm btn-primary pull-right margin-right-20"
+							i18n:translate="">Add comment</button>
+				</span>
+			</form>
+		</div>
+	</div>
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/common/zmi/templates/review-notification.pt	Thu Jun 02 15:31:37 2016 +0200
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml" i18n:domain="pyams_content">
+<head>
+	<title i18n:translate="">
+		[<tal:var i18n:name="service_name" tal:content="options['settings'].service_name">Service name</tal:var>]
+		You are requested for a content review
+	</title>
+	<meta http-equiv="CONTENT-TYPE" content="text/html; charset=utf-8" />
+	<meta charset="utf-8" />
+</head>
+<body>
+	<p i18n:translate="">Hello,</p>
+	<p i18n:translate="">
+		You have been requested by <span i18n:name="sender" tal:content="options['sender']">sender</span>
+		to make a review of a content called « <strong i18n:name="title" tal:content="i18n:context.title">title</strong> »
+		which has been created on publication platform « <span i18n:name="service_name"
+			tal:content="options['settings'].service_name">Service name</span> ».
+	</p>
+	<p i18n:translate="">Comment associated with this request is:</p>
+	<pre style="padding: 5px 20px;" tal:content="options['comment']">comment</pre>
+	<p i18n:translate="">To review and comment this publication, please use the following link: <a i18n:name="target"
+			tal:attributes="href extension:absolute_url(context, 'admin.html')"
+			tal:content="i18n:context.title"></a></p>
+	<br />
+	<pre tal:content="options['settings'].signature">signature</pre>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/skin/resources/css/pyams_content.css	Thu Jun 02 15:31:37 2016 +0200
@@ -0,0 +1,42 @@
+.ams-widget.comments .widget-body {
+  position: fixed;
+  height: calc(100% - 310px);
+}
+.ams-widget.comments .widget-body .chat-body {
+  position: relative;
+  height: 100%;
+}
+.ams-widget.comments .widget-body .chat-footer {
+  position: fixed;
+  bottom: 10px;
+}
+.ams-widget.comments .widget-body,
+.ams-widget.comments .widget-body .chat-footer {
+  width: calc(100% - 240px);
+}
+@media (max-width: 767px) {
+  .ams-widget.comments .widget-body,
+  .ams-widget.comments .widget-body .chat-footer {
+    width: calc(100% - 10px);
+  }
+}
+@media (min-width: 768px) and (max-width: 979px) {
+  .ams-widget.comments .widget-body,
+  .ams-widget.comments .widget-body .chat-footer {
+    width: calc(100% - 20px);
+  }
+}
+.minified .ams-widget.comments .widget-body,
+.minified .ams-widget.comments .widget-body .chat-footer {
+  width: calc(100% - 65px);
+}
+@media (max-width: 767px) {
+  .minified .ams-widget.comments .widget-body,
+  .minified .ams-widget.comments .widget-body .chat-footer {
+    width: calc(100% - 55px);
+  }
+}
+.hidden-menu .ams-widget.comments .widget-body,
+.hidden-menu .ams-widget.comments .widget-body .chat-footer {
+  width: calc(100% - 30px);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/skin/resources/css/pyams_content.min.css	Thu Jun 02 15:31:37 2016 +0200
@@ -0,0 +1,1 @@
+.ams-widget.comments .widget-body{position:fixed;height:calc(100% - 310px)}.ams-widget.comments .widget-body .chat-body{position:relative;height:100%}.ams-widget.comments .widget-body .chat-footer{position:fixed;bottom:10px}.ams-widget.comments .widget-body,.ams-widget.comments .widget-body .chat-footer{width:calc(100% - 240px)}@media(max-width:767px){.ams-widget.comments .widget-body,.ams-widget.comments .widget-body .chat-footer{width:calc(100% - 10px)}}@media(min-width:768px) and (max-width:979px){.ams-widget.comments .widget-body,.ams-widget.comments .widget-body .chat-footer{width:calc(100% - 20px)}}.minified .ams-widget.comments .widget-body,.minified .ams-widget.comments .widget-body .chat-footer{width:calc(100% - 65px)}@media(max-width:767px){.minified .ams-widget.comments .widget-body,.minified .ams-widget.comments .widget-body .chat-footer{width:calc(100% - 55px)}}.hidden-menu .ams-widget.comments .widget-body,.hidden-menu .ams-widget.comments .widget-body .chat-footer{width:calc(100% - 30px)}
\ No newline at end of file
--- a/src/pyams_content/skin/resources/js/pyams_content.js	Thu Jun 02 15:30:56 2016 +0200
+++ b/src/pyams_content/skin/resources/js/pyams_content.js	Thu Jun 02 15:31:37 2016 +0200
@@ -333,6 +333,82 @@
 									   });
 				}
 			}
+		},
+
+
+		/**
+		 * Review comments management
+		 */
+		review: {
+
+			timer: null,
+			timer_duration: {
+				general: 30000,
+				chat: 5000
+			},
+
+			initComments: function(element) {
+				var chat = $('.chat-body', element);
+				chat.animate({scrollTop: chat[0].scrollHeight}, 1000);
+				clearInterval(PyAMS_content.review.timer);
+				PyAMS_content.review.timer = setInterval(PyAMS_content.review.updateComments,
+														 PyAMS_content.review.timer_duration.chat);
+				MyAMS.skin.registerCleanCallback(PyAMS_content.review.cleanCommentsCallback);
+			},
+
+			cleanCommentsCallback: function() {
+				clearInterval(PyAMS_content.review.timer);
+				PyAMS_content.review.timer = setInterval(PyAMS_content.review.updateComments,
+														 PyAMS_content.review.timer_duration.general);
+			},
+
+			updateComments: function() {
+				var badge = $('.badge', 'nav a[href="#review-comments.html"]'),
+					count;
+				var chat = $('.chat-body', '.widget-body');
+				if (chat.exists()) {
+					count = $('.message', chat).length;
+				} else {
+					count = parseInt(badge.text());
+				}
+				MyAMS.ajax.post('get-last-review-comments.json', {count: count}, function(result) {
+					if (chat.exists()) {
+						badge.removeClass('bg-color-danger')
+							 .addClass('bg-color-info');
+					}
+					if (count !== result.count) {
+						badge.text(result.count).removeClass('hidden');
+						if (chat.exists()) {
+							$('.messages', chat).append(result.content);
+							chat.animate({scrollTop: chat[0].scrollHeight}, 1000);
+						}
+						if (!chat.exists()) {
+							badge.removeClass('bg-color-info')
+								 .addClass('bg-color-danger')
+								 .animate({'padding': '3px 12px 2px',
+										   'margin-right': '9px'}, 'slow', function() {
+									$(this).animate({'padding': '3px 6px 2px',
+													 'margin-right': '15px'}, 'slow');
+								 });
+						}
+					}
+				});
+			},
+
+			initCommentData: function(veto) {
+				var chat = $('.chat-body', '.widget-body');
+				return {count: $('.message', chat).length};
+			},
+
+			addCommentCallback: function(options) {
+				var form = $(this);
+				var widget = form.parents('.widget-body');
+				$('.messages', widget).append(options.content);
+				$('textarea[name="comment"]', form).val('');
+				var chat = $('.chat-body', widget);
+				chat.animate({scrollTop: chat[0].scrollHeight}, 1000);
+				$('.badge', 'nav a[href="#review-comments.html"]').text(options.count).removeClass('hidden');
+			}
 		}
 	};
 
@@ -352,7 +428,16 @@
 			case 'galleries_container':
 				PyAMS_content.galleries.refreshContainer(settings);
 				break;
+			case 'review_comments':
+				PyAMS_content.review.updateComments();
+				break;
 		}
 	});
 
+	var badge = $('.badge', 'nav a[href="#review-comments.html"]');
+	if (badge.exists()) {
+		PyAMS_content.review.timer = setInterval(PyAMS_content.review.updateComments,
+												 PyAMS_content.review.timer_duration.general);
+	}
+
 })(jQuery, this);
--- a/src/pyams_content/skin/resources/js/pyams_content.min.js	Thu Jun 02 15:30:56 2016 +0200
+++ b/src/pyams_content/skin/resources/js/pyams_content.min.js	Thu Jun 02 15:31:37 2016 +0200
@@ -1,1 +1,1 @@
-(function(b,a){var d=a.MyAMS;var c={TinyMCE:{initEditor:function(e){e.image_list=c.TinyMCE.getImagesList;e.link_list=c.TinyMCE.getLinksList;return e},getImagesList:function(e){return d.ajax.post("get-images-list.json",{},e)},getLinksList:function(e){return d.ajax.post("get-links-list.json",{},e)}},profile:{switchFavorite:function(){var f=b(this);var e=f.data("sequence-oid");d.ajax.post("switch-user-favorite.json",{oid:e},function(g,h){if(g.favorite){f.removeClass("fa-star-o").addClass("fa-star")}else{f.removeClass("fa-star").addClass("fa-star-o")}})}},extfiles:{refresh:function(f){if(typeof(f)==="string"){f=JSON.parse(f)}var e=b('select[name="form.widgets.files:list"]');var g=e.data("select2");b("<option></option>").attr("value",f.new_file.id).attr("selected","selected").text(f.new_file.text).appendTo(e);var h=e.select2("data");h.push(f.new_file);e.select2("data",h);g.results.empty();g.opts.populateResults.call(g,g.results,f.files,{term:""})},refreshContainer:function(g){var e=b('tr[data-ams-element-name="'+g.object_name+'"]');var f=b("span.count",b("div.action.extfiles",e));if(g.nb_files>0){f.text("("+g.nb_files+")")}else{f.text("")}}},links:{refresh:function(f){if(typeof(f)==="string"){f=JSON.parse(f)}var e=b('select[name="form.widgets.links:list"]');var g=e.data("select2");b("<option></option>").attr("value",f.new_link.id).attr("selected","selected").text(f.new_link.text).appendTo(e);var h=e.select2("data");h.push(f.new_link);e.select2("data",h);g.results.empty();g.opts.populateResults.call(g,g.results,f.links,{term:""})},refreshContainer:function(g){var e=b('tr[data-ams-element-name="'+g.object_name+'"]');var f=b("span.count",b("div.action.links",e));if(g.nb_links>0){f.text("("+g.nb_links+")")}else{f.text("")}}},galleries:{refresh:function(f){if(typeof(f)==="string"){f=JSON.parse(f)}var e=b('select[name="form.widgets.galleries:list"]');var g=e.data("select2");b("<option></option>").attr("value",f.new_gallery.id).attr("selected","selected").text(f.new_gallery.text).appendTo(e);var h=e.select2("data");h.push(f.new_gallery);e.select2("data",h);g.results.empty();g.opts.populateResults.call(g,g.results,f.galleries,{term:""})},setOrder:function(g,h){if(h&&h.item.hasClass("already-dropped")){return}var e=h.item.parents(".gallery");var f=b(".image",e).listattr("data-ams-element-name");d.ajax.post(e.data("ams-location")+"/set-images-order.json",{images:JSON.stringify(f)})},removeFile:function(e){return function(){var f=b(this);d.skin.bigBox({title:d.i18n.WARNING,content:'<i class="text-danger fa fa-2x fa-bell shake animated"></i>&nbsp; '+d.i18n.DELETE_WARNING,buttons:d.i18n.BTN_OK_CANCEL},function(j){if(j===d.i18n.BTN_OK){var i=f.parents(".gallery");var h=i.data("ams-location");var k=f.parents(".image");var g=k.data("ams-element-name");d.ajax.post(h+"/delete-element.json",{object_name:g},function(l,m){k.remove()})}})}},refreshContainer:function(g){var e=b('tr[data-ams-element-name="'+g.object_name+'"]');var f=b("span.count",b("div.action.galleries",e));if(g.nb_galleries>0){f.text("("+g.nb_galleries+")")}else{f.text("")}}},paragraphs:{switchVisibility:function(e){return function(){var h=b(this);var f=h.parents("tr");var g=f.parents("table");d.ajax.post(g.data("ams-location")+"/set-paragraph-visibility.json",{object_name:f.data("ams-element-name")},function(i,j){if(i.visible){b("i",h).attr("class","fa fa-fw fa-eye")}else{b("i",h).attr("class","fa fa-fw fa-eye-slash text-danger")}})}},refreshParagraph:function(g){var f=b('table[id="paragraphs_list"]');var e=b('tr[data-ams-element-name="'+g.object_name+'"]',f);if(g.visible){b("i",b("td.switcher",e)).removeClass("fa-eye-slash text-danger").addClass("fa-eye")}else{b("i",b("td.switcher",e)).removeClass("fa-eye").addClass("fa-eye-slash text-danger")}b("span.title",e).text(g.title||"--")},switchEditor:function(g){var j=b(this);var i=b("i",j);var k=j.parents("td");var h=b(".editor",k);var e=j.parents("tr");if(i.hasClass("fa-plus-square-o")){var f=e.parents("table");h.html('<h1 class="loading"><i class="fa fa-2x fa-gear fa-spin"></i></h1>');d.ajax.post(f.data("ams-location")+"/get-paragraph-editor.json",{object_name:e.data("ams-element-name")},function(l){h.html(l);if(l){d.initContent(h);i.removeClass("fa-plus-square-o").addClass("fa-minus-square-o");e.data("ams-disabled-handlers",true)}})}else{d.skin.cleanContainer(h);h.empty();i.removeClass("fa-minus-square-o").addClass("fa-plus-square-o");e.removeData("ams-disabled-handlers")}},switchAllEditors:function(f){var h=b(this);var g=b("i",h);var e=h.parents("table");if(g.hasClass("fa-plus-square-o")){g.removeClass("fa-plus-square-o").addClass("fa-cog fa-spin");d.ajax.post(e.data("ams-location")+"/get-paragraphs-editors.json",{},function(j){for(var k in j){if(!j.hasOwnProperty(k)){continue}var i=b('tr[data-ams-element-name="'+k+'"]',e);var l=b(".editor",i);if(l.is(":empty")){l.html(j[k]);d.initContent(l)}b(".fa-plus-square-o",i).removeClass("fa-plus-square-o").addClass("fa-minus-square-o");i.data("ams-disabled-handlers",true)}if(!b("i.fa-plus-square-o",b("tbody",e)).exists()){g.removeClass("fa-cog fa-spin").addClass("fa-minus-square-o")}})}else{b(".editor",e).each(function(){d.skin.cleanContainer(b(this));b(this).empty()});b(".fa-minus-square-o",e).removeClass("fa-minus-square-o").addClass("fa-plus-square-o");b("tr",e).removeData("ams-disabled-handlers")}}},themes:{initExtracts:function(g){var f=b('select[name="form.widgets.thesaurus_name:list"]',g);var e=f.val();var i=b('select[name="form.widgets.extract_name:list"]',g);var h=i.val();if(e){d.jsonrpc.post("getExtracts",{thesaurus_name:e},{url:"/api/thesaurus/json"},function(j){i.empty();b(j.result).each(function(){b("<option></option>").attr("value",this.id).attr("selected",this.id===h).text(this.text).appendTo(i)})})}i.attr("data-ams-events-handlers",'{"select2-open": "PyAMS_content.themes.getExtracts"}')},getExtracts:function(h){var e=b(h.currentTarget);var g=e.parents("form");var f=b('select[name="form.widgets.thesaurus_name:list"]',g).val();if(f){d.jsonrpc.post("getExtracts",{thesaurus_name:f},{url:"/api/thesaurus/json"},function(k){var j=b('select[name="form.widgets.extract_name:list"]',g);var i=j.data("select2");i.results.empty();i.opts.populateResults.call(i,i.results,k.result,{term:""})})}}}};a.PyAMS_content=c;b(a.document).on("PyAMS_content.changed_item",function(f,e){switch(e.object_type){case"paragraph":c.paragraphs.refreshParagraph(e);break;case"extfiles_container":c.extfiles.refreshContainer(e);break;case"links_container":c.links.refreshContainer(e);break;case"galleries_container":c.galleries.refreshContainer(e);break}})})(jQuery,this);
\ No newline at end of file
+(function(c,b){var e=b.MyAMS;var d={TinyMCE:{initEditor:function(f){f.image_list=d.TinyMCE.getImagesList;f.link_list=d.TinyMCE.getLinksList;return f},getImagesList:function(f){return e.ajax.post("get-images-list.json",{},f)},getLinksList:function(f){return e.ajax.post("get-links-list.json",{},f)}},profile:{switchFavorite:function(){var g=c(this);var f=g.data("sequence-oid");e.ajax.post("switch-user-favorite.json",{oid:f},function(h,i){if(h.favorite){g.removeClass("fa-star-o").addClass("fa-star")}else{g.removeClass("fa-star").addClass("fa-star-o")}})}},extfiles:{refresh:function(g){if(typeof(g)==="string"){g=JSON.parse(g)}var f=c('select[name="form.widgets.files:list"]');var h=f.data("select2");c("<option></option>").attr("value",g.new_file.id).attr("selected","selected").text(g.new_file.text).appendTo(f);var i=f.select2("data");i.push(g.new_file);f.select2("data",i);h.results.empty();h.opts.populateResults.call(h,h.results,g.files,{term:""})},refreshContainer:function(h){var f=c('tr[data-ams-element-name="'+h.object_name+'"]');var g=c("span.count",c("div.action.extfiles",f));if(h.nb_files>0){g.text("("+h.nb_files+")")}else{g.text("")}}},links:{refresh:function(g){if(typeof(g)==="string"){g=JSON.parse(g)}var f=c('select[name="form.widgets.links:list"]');var h=f.data("select2");c("<option></option>").attr("value",g.new_link.id).attr("selected","selected").text(g.new_link.text).appendTo(f);var i=f.select2("data");i.push(g.new_link);f.select2("data",i);h.results.empty();h.opts.populateResults.call(h,h.results,g.links,{term:""})},refreshContainer:function(h){var f=c('tr[data-ams-element-name="'+h.object_name+'"]');var g=c("span.count",c("div.action.links",f));if(h.nb_links>0){g.text("("+h.nb_links+")")}else{g.text("")}}},galleries:{refresh:function(g){if(typeof(g)==="string"){g=JSON.parse(g)}var f=c('select[name="form.widgets.galleries:list"]');var h=f.data("select2");c("<option></option>").attr("value",g.new_gallery.id).attr("selected","selected").text(g.new_gallery.text).appendTo(f);var i=f.select2("data");i.push(g.new_gallery);f.select2("data",i);h.results.empty();h.opts.populateResults.call(h,h.results,g.galleries,{term:""})},setOrder:function(h,i){if(i&&i.item.hasClass("already-dropped")){return}var f=i.item.parents(".gallery");var g=c(".image",f).listattr("data-ams-element-name");e.ajax.post(f.data("ams-location")+"/set-images-order.json",{images:JSON.stringify(g)})},removeFile:function(f){return function(){var g=c(this);e.skin.bigBox({title:e.i18n.WARNING,content:'<i class="text-danger fa fa-2x fa-bell shake animated"></i>&nbsp; '+e.i18n.DELETE_WARNING,buttons:e.i18n.BTN_OK_CANCEL},function(k){if(k===e.i18n.BTN_OK){var j=g.parents(".gallery");var i=j.data("ams-location");var l=g.parents(".image");var h=l.data("ams-element-name");e.ajax.post(i+"/delete-element.json",{object_name:h},function(m,n){l.remove()})}})}},refreshContainer:function(h){var f=c('tr[data-ams-element-name="'+h.object_name+'"]');var g=c("span.count",c("div.action.galleries",f));if(h.nb_galleries>0){g.text("("+h.nb_galleries+")")}else{g.text("")}}},paragraphs:{switchVisibility:function(f){return function(){var i=c(this);var g=i.parents("tr");var h=g.parents("table");e.ajax.post(h.data("ams-location")+"/set-paragraph-visibility.json",{object_name:g.data("ams-element-name")},function(j,k){if(j.visible){c("i",i).attr("class","fa fa-fw fa-eye")}else{c("i",i).attr("class","fa fa-fw fa-eye-slash text-danger")}})}},refreshParagraph:function(h){var g=c('table[id="paragraphs_list"]');var f=c('tr[data-ams-element-name="'+h.object_name+'"]',g);if(h.visible){c("i",c("td.switcher",f)).removeClass("fa-eye-slash text-danger").addClass("fa-eye")}else{c("i",c("td.switcher",f)).removeClass("fa-eye").addClass("fa-eye-slash text-danger")}c("span.title",f).text(h.title||"--")},switchEditor:function(h){var k=c(this);var j=c("i",k);var l=k.parents("td");var i=c(".editor",l);var f=k.parents("tr");if(j.hasClass("fa-plus-square-o")){var g=f.parents("table");i.html('<h1 class="loading"><i class="fa fa-2x fa-gear fa-spin"></i></h1>');e.ajax.post(g.data("ams-location")+"/get-paragraph-editor.json",{object_name:f.data("ams-element-name")},function(m){i.html(m);if(m){e.initContent(i);j.removeClass("fa-plus-square-o").addClass("fa-minus-square-o");f.data("ams-disabled-handlers",true)}})}else{e.skin.cleanContainer(i);i.empty();j.removeClass("fa-minus-square-o").addClass("fa-plus-square-o");f.removeData("ams-disabled-handlers")}},switchAllEditors:function(g){var i=c(this);var h=c("i",i);var f=i.parents("table");if(h.hasClass("fa-plus-square-o")){h.removeClass("fa-plus-square-o").addClass("fa-cog fa-spin");e.ajax.post(f.data("ams-location")+"/get-paragraphs-editors.json",{},function(k){for(var l in k){if(!k.hasOwnProperty(l)){continue}var j=c('tr[data-ams-element-name="'+l+'"]',f);var m=c(".editor",j);if(m.is(":empty")){m.html(k[l]);e.initContent(m)}c(".fa-plus-square-o",j).removeClass("fa-plus-square-o").addClass("fa-minus-square-o");j.data("ams-disabled-handlers",true)}if(!c("i.fa-plus-square-o",c("tbody",f)).exists()){h.removeClass("fa-cog fa-spin").addClass("fa-minus-square-o")}})}else{c(".editor",f).each(function(){e.skin.cleanContainer(c(this));c(this).empty()});c(".fa-minus-square-o",f).removeClass("fa-minus-square-o").addClass("fa-plus-square-o");c("tr",f).removeData("ams-disabled-handlers")}}},themes:{initExtracts:function(h){var g=c('select[name="form.widgets.thesaurus_name:list"]',h);var f=g.val();var j=c('select[name="form.widgets.extract_name:list"]',h);var i=j.val();if(f){e.jsonrpc.post("getExtracts",{thesaurus_name:f},{url:"/api/thesaurus/json"},function(k){j.empty();c(k.result).each(function(){c("<option></option>").attr("value",this.id).attr("selected",this.id===i).text(this.text).appendTo(j)})})}j.attr("data-ams-events-handlers",'{"select2-open": "PyAMS_content.themes.getExtracts"}')},getExtracts:function(i){var f=c(i.currentTarget);var h=f.parents("form");var g=c('select[name="form.widgets.thesaurus_name:list"]',h).val();if(g){e.jsonrpc.post("getExtracts",{thesaurus_name:g},{url:"/api/thesaurus/json"},function(l){var k=c('select[name="form.widgets.extract_name:list"]',h);var j=k.data("select2");j.results.empty();j.opts.populateResults.call(j,j.results,l.result,{term:""})})}}},review:{timer:null,timer_duration:{general:30000,chat:5000},initComments:function(g){var f=c(".chat-body",g);f.animate({scrollTop:f[0].scrollHeight},1000);clearInterval(d.review.timer);d.review.timer=setInterval(d.review.updateComments,d.review.timer_duration.chat);e.skin.registerCleanCallback(d.review.cleanCommentsCallback)},cleanCommentsCallback:function(){clearInterval(d.review.timer);d.review.timer=setInterval(d.review.updateComments,d.review.timer_duration.general)},updateComments:function(){var f=c(".badge",'nav a[href="#review-comments.html"]'),h;var g=c(".chat-body",".widget-body");if(g.exists()){h=c(".message",g).length}else{h=parseInt(f.text())}e.ajax.post("get-last-review-comments.json",{count:h},function(i){if(g.exists()){f.removeClass("bg-color-danger").addClass("bg-color-info")}if(h!==i.count){f.text(i.count).removeClass("hidden");if(g.exists()){c(".messages",g).append(i.content);g.animate({scrollTop:g[0].scrollHeight},1000)}if(!g.exists()){f.removeClass("bg-color-info").addClass("bg-color-danger").animate({padding:"3px 12px 2px","margin-right":"9px"},"slow",function(){c(this).animate({padding:"3px 6px 2px","margin-right":"15px"},"slow")})}}})},initCommentData:function(f){var g=c(".chat-body",".widget-body");return{count:c(".message",g).length}},addCommentCallback:function(g){var h=c(this);var i=h.parents(".widget-body");c(".messages",i).append(g.content);c('textarea[name="comment"]',h).val("");var f=c(".chat-body",i);f.animate({scrollTop:f[0].scrollHeight},1000);c(".badge",'nav a[href="#review-comments.html"]').text(g.count).removeClass("hidden")}}};b.PyAMS_content=d;c(b.document).on("PyAMS_content.changed_item",function(g,f){switch(f.object_type){case"paragraph":d.paragraphs.refreshParagraph(f);break;case"extfiles_container":d.extfiles.refreshContainer(f);break;case"links_container":d.links.refreshContainer(f);break;case"galleries_container":d.galleries.refreshContainer(f);break;case"review_comments":d.review.updateComments();break}});var a=c(".badge",'nav a[href="#review-comments.html"]');if(a.exists()){d.review.timer=setInterval(d.review.updateComments,d.review.timer_duration.general)}})(jQuery,this);
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/skin/resources/less/pyams_content.less	Thu Jun 02 15:31:37 2016 +0200
@@ -0,0 +1,42 @@
+.ams-widget.comments {
+
+	.widget-body {
+		position: fixed;
+		height: ~"calc(100% - 310px)";
+
+		.chat-body {
+			position: relative;
+			height: 100%;
+		}
+
+		.chat-footer {
+			position: fixed;
+			bottom: 10px;
+		}
+
+		&,
+		.chat-footer {
+			width: ~"calc(100% - 240px)";
+
+			@media (max-width: 767px) {
+				width: ~"calc(100% - 10px)";
+			}
+			@media (min-width: 768px) and (max-width: 979px) {
+				width: ~"calc(100% - 20px)";
+			}
+
+			.minified & {
+				width: ~"calc(100% - 65px)";
+
+				@media (max-width: 767px) {
+					& {
+						width: ~"calc(100% - 55px)";
+					}
+				}
+			}
+			.hidden-menu & {
+				width: ~"calc(100% - 30px)";
+			}
+		}
+	}
+}