Created 'features' module to handle shared features outside of pure content management
authorThierry Florac <thierry.florac@onf.fr>
Fri, 10 Nov 2017 11:46:27 +0100
changeset 238 2dc445ad2cf5
parent 237 ccd42b19051a
child 239 b3b7d4bf63f7
Created 'features' module to handle shared features outside of pure content management
src/pyams_content/features/__init__.py
src/pyams_content/features/checker/__init__.py
src/pyams_content/features/checker/interfaces.py
src/pyams_content/features/checker/zmi/__init__.py
src/pyams_content/features/preview/__init__.py
src/pyams_content/features/preview/interfaces.py
src/pyams_content/features/preview/zmi/__init__.py
src/pyams_content/features/preview/zmi/interfaces.py
src/pyams_content/features/review/__init__.py
src/pyams_content/features/review/interfaces.py
src/pyams_content/features/review/zmi/__init__.py
src/pyams_content/features/review/zmi/templates/review-add-comment.pt
src/pyams_content/features/review/zmi/templates/review-comments-json.pt
src/pyams_content/features/review/zmi/templates/review-comments.pt
src/pyams_content/features/review/zmi/templates/review-notification.pt
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
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/checker/__init__.py	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,47 @@
+#
+# 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.features.checker.interfaces import VALUE_OK
+
+# import packages
+from pyams_utils.adapter import ContextAdapter
+from pyams_utils.request import check_request
+
+
+class BaseContentChecker(ContextAdapter):
+    """Base content checker"""
+
+    label = None
+    weight = 1
+    sep = '<br />'
+
+    def get_check_output(self, request=None):
+        if request is None:
+            request = check_request()
+        translate = request.localizer.translate
+        output = self.inner_check(request)
+        if output:
+            output = [self.sep.join(output)]
+            output.insert(0, '<div class="padding-left-20">')
+            output.append('</div>')
+        else:
+            output.append(translate(VALUE_OK) + '<br />')
+        return '\n'.join(output)
+
+    def inner_check(self, request):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/checker/interfaces.py	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,39 @@
+#
+# 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
+
+# import packages
+from zope.interface import Interface, Attribute
+
+from pyams_content import _
+
+
+VALUE_OK = '<span class="text-success">OK</span>'
+MISSING_VALUE = _(' - {field}: <span class="text-danger">no value</span>')
+MISSING_LANG_VALUE = _(' - {field} ({lang}): <span class="text-danger">no value</span>')
+ERROR_VALUE = _(' - {field}: <span class="text-danger">{message}</span>')
+
+
+class IContentChecker(Interface):
+    """Content checker interface"""
+
+    label = Attribute("Adapter label")
+    weight = Attribute("Adapter weight")
+
+    def get_check_output(self, request=None):
+        """Gte context check as HTML output"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/checker/zmi/__init__.py	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,80 @@
+#
+# 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.features.checker.interfaces import IContentChecker
+from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
+from pyams_form.interfaces.form import IWidgetsSuffixViewletsManager
+from pyams_skin.interfaces import IInnerPage
+from pyams_skin.interfaces.viewlet import IContextActions
+from pyams_skin.layer import IPyAMSLayer
+from pyams_zmi.layer import IAdminLayer
+
+# import packages
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_viewlet.viewlet import viewlet_config, Viewlet
+from pyams_zmi.form import AdminDialogDisplayForm
+from z3c.form import field
+from zope.interface import Interface
+
+from pyams_content import _
+
+
+@viewlet_config(name='check-content.menu', context=Interface, layer=IAdminLayer,
+                view=IInnerPage, manager=IContextActions, permission=MANAGE_CONTENT_PERMISSION, weight=20)
+class ContentCheckerMenu(ToolbarMenuItem):
+    """Content checker menu item"""
+
+    label = _("Check content...")
+    label_css_class = 'fa fa-fw fa-check-square-o'
+
+    url = 'check-content.html'
+    modal_target = True
+
+
+@pagelet_config(name='check-content.html', context=Interface, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class ContentCheckerForm(AdminDialogDisplayForm):
+    """Content checker display form"""
+
+    legend = _("Content check")
+    icon_css_class = 'fa fa-fw fa-check-square-o'
+
+    fields = field.Fields(Interface)
+
+
+@viewlet_config(name='check-content', context=Interface, layer=IAdminLayer, view=ContentCheckerForm,
+                manager=IWidgetsSuffixViewletsManager, weight=1)
+class ContentCheckerWidgetsSuffix(Viewlet):
+    """Content checker widgets suffix"""
+
+    def render(self):
+        output = []
+        registry = self.request.registry
+        translate = self.request.localizer.translate
+        for name, checker in sorted(registry.getAdapters((self.context, ), IContentChecker),
+                                    key=lambda x: x[1].weight):
+            header = '<strong>{0}</strong>'.format(translate(checker.label))
+            checker_output = checker.get_check_output()
+            if checker_output:
+                output.append(translate(_('{0}:')).format(header))
+                output.append(checker_output)
+        if not output:
+            translate = self.request.localizer.translate
+            output.append(translate(_("No checker available. This content is clean!")))
+        return '\n'.join(output)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/preview/__init__.py	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,20 @@
+#
+# 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
+
+# import packages
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/preview/interfaces.py	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,28 @@
+#
+# 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
+
+# import packages
+from zope.interface import Interface
+
+
+class IPreviewTarget(Interface):
+    """Preview target marker interface
+
+    This interface is used to mark contents which can handle preview.
+    """
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/preview/zmi/__init__.py	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,60 @@
+#
+# 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.features.preview.interfaces import IPreviewTarget
+from pyams_content.features.preview.zmi.interfaces import IPreviewForm
+from pyams_skin.interfaces.viewlet import IToolbarViewletManager
+from pyams_skin.layer import IPyAMSLayer
+from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
+from pyams_zmi.layer import IAdminLayer
+
+# import packages
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.toolbar import ToolbarAction
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogDisplayForm
+from z3c.form import field
+from zope.interface import implementer, Interface
+
+from pyams_content import _
+
+
+@viewlet_config(name='preview.action', context=IPreviewTarget, layer=IAdminLayer, view=Interface,
+                manager=IToolbarViewletManager, permisison=VIEW_SYSTEM_PERMISSION, weight=50)
+class PreviewAction(ToolbarAction):
+    """Content preview action"""
+
+    label = _("Preview")
+
+    group_css_class = 'btn-group margin-right-10'
+    label_css_class = 'fa fa-newspaper-o'
+    css_class = 'btn btn-xs btn-default'
+
+    url = 'preview.html'
+    modal_target = True
+
+
+@pagelet_config(name='preview.html', context=IPreviewTarget, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
+@implementer(IPreviewForm)
+class PreviewForm(AdminDialogDisplayForm):
+    """Content preview form"""
+
+    legend = _("Content preview")
+    dialog_class = 'modal-max'
+
+    fields = field.Fields(Interface)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/preview/zmi/interfaces.py	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,25 @@
+#
+# 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
+
+# import packages
+from zope.interface import Interface
+
+
+class IPreviewForm(Interface):
+    """Preview form marker interface"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/review/__init__.py	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,233 @@
+#
+# 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 import READER_ROLE
+from pyams_content.features.review.interfaces import IReviewManager, IReviewComment, IReviewComments, \
+    REVIEW_COMMENTS_ANNOTATION_KEY, CommentAddedEvent, ICommentAddedEvent, IReviewTarget
+from pyams_content.shared.common.interfaces import IWfSharedContentRoles
+from pyams_i18n.interfaces import II18n
+from pyams_mail.interfaces import IPrincipalMailInfo
+from pyams_security.interfaces import ISecurityManager, IProtectedObject
+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.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, query_request
+from pyams_utils.url import absolute_url
+from pyramid.events import subscriber
+from pyramid.threadlocal import get_current_registry
+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"""
+
+    reviewers = FieldProperty(IReviewComments['reviewers'])
+
+    def clear(self):
+        for k in self.keys()[:]:
+            del self[k]
+
+    def add_comment(self, comment):
+        uuid = str(uuid4())
+        self[uuid] = comment
+        reviewers = self.reviewers or set()
+        reviewers.add(comment.owner)
+        self.reviewers = reviewers
+        get_current_registry().notify(CommentAddedEvent(self.__parent__, comment))
+
+
+@adapter_config(context=IReviewTarget, 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=IReviewTarget, 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=IReviewTarget, provides=ISublocations)
+class SharedContentReviewCommentsSublocations(ContextAdapter):
+    """Shared content review comments sub-location adapter"""
+
+    def sublocations(self):
+        return IReviewComments(self.context).values()
+
+
+@adapter_config(context=IReviewTarget, 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
+        if settings.enable_notifications:
+            mailer = query_utility(IMailer, name=settings.mailer)
+        else:
+            mailer = None
+        # 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 \
+                    (mailer is not None) 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
+
+
+#
+# Review comment notification
+#
+
+try:
+    from pyams_notify.interfaces import INotification, INotificationHandler
+    from pyams_notify.event import Notification
+except ImportError:
+    pass
+else:
+
+    @subscriber(ICommentAddedEvent)
+    def handle_new_comment(event):
+        """Handle new review comment"""
+        request = query_request()
+        if request is None:
+            return
+        content = event.object
+        translate = request.localizer.translate
+        notification = Notification(request=request,
+                                    context=content,
+                                    source=event.comment.owner,
+                                    action='notify',
+                                    category='content.review',
+                                    message=translate(_("A new comment was added on content « {0} »")).format(
+                                        II18n(content).query_attribute('title', request=request)),
+                                    url=absolute_url(content, request, 'admin#review-comments.html'),
+                                    comments=IReviewComments(content))
+        notification.send()
+
+
+    @adapter_config(name='content.review', context=INotification, provides=INotificationHandler)
+    class ContentReviewNotificationHandler(ContextAdapter):
+        """Content review notification handler"""
+
+        def get_target(self):
+            context = self.context.context
+            principals = set()
+            protection = IProtectedObject(context, None)
+            if protection is not None:
+                principals |= protection.get_principals(READER_ROLE)
+            comments = self.context.user_data.get('comments')
+            if comments is not None:
+                principals |= comments.reviewers
+            source_id = self.context.source['id']
+            if source_id in principals:
+                principals.remove(source_id)
+            return {'principals': tuple(principals)}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/review/interfaces.py	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,101 @@
+#
+# 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
+from zope.interface.interfaces import IObjectEvent, ObjectEvent
+
+# import packages
+from pyams_security.schema import Principal, PrincipalsSet
+from zope.container.constraints import contains, containers
+from zope.interface import implementer, Interface, Attribute
+from zope.schema import Text, Choice, Datetime
+
+from pyams_content import _
+
+
+COMMENT_TYPES = {'request': _("Review request"),
+                 'comment': _("Reviewer comment")}
+
+
+class ICommentAddedEvent(IObjectEvent):
+    """Comment added event interface"""
+
+    comment = Attribute("New comment")
+
+
+@implementer(ICommentAddedEvent)
+class CommentAddedEvent(ObjectEvent):
+    """Comment added event"""
+
+    def __init__(self, object, comment):
+        super(CommentAddedEvent, self).__init__(object)
+        self.comment = 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)
+
+    reviewers = PrincipalsSet(title=_("Reviewers list"),
+                              description=_("List of principals which reviewed the comment"),
+                              required=False)
+
+    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"""
+
+
+class IReviewTarget(Interface):
+    """Review target marker interface
+
+    This interface is used to mark contents which can handle review.
+    """
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/review/zmi/__init__.py	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,281 @@
+#
+# 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.features.review.interfaces import IReviewManager, IReviewComments, IReviewTarget
+from pyams_security.interfaces import ISecurityManager
+from pyams_security.interfaces.profile import IPublicProfile
+from pyams_skin.interfaces.viewlet import IContextActions, IWidgetTitleViewletManager
+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.features.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.viewlet.menu import MenuItem
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem, JsToolbarAction
+from pyams_template.template import template_config, get_view_template
+from pyams_utils.date import get_age, format_datetime
+from pyams_utils.registry import get_utility, query_utility
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogAddForm
+from pyams_zmi.view import InnerAdminView
+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=IReviewTarget, 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=IReviewTarget, 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)
+        if 'comment' in self.widgets:
+            self.widgets['comment'].widget_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=IReviewTarget, 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),
+                    'events': [{
+                        'event': 'PyAMS_content.changed_item',
+                        'options': {'handler': 'PyAMS_content.review.updateComments'}
+                    }]}
+        else:
+            return {'status': 'info',
+                    'message': translate(_("Request successful. No new notification have been sent")),
+                    'events': [{
+                        'event': 'PyAMS_content.changed_item',
+                        'options': {'handler': 'PyAMS_content.review.updateComments'}
+                    }]}
+
+
+#
+# Share contents comments
+#
+
+@viewlet_config(name='review-comments.menu', context=IReviewTarget, 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=IReviewTarget, layer=IPyAMSLayer,
+                permission=COMMENT_CONTENT_PERMISSION)
+@template_config(template='templates/review-comments.pt', layer=IPyAMSLayer)
+class SharedContentReviewCommentsView(InnerAdminView):
+    """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_date(self, comment):
+        return format_datetime(comment.creation_date)
+
+    def get_age(self, comment):
+        return get_age(comment.creation_date)
+
+
+@viewlet_config(name='add-review-comment.action', context=IReviewTarget, layer=IAdminLayer,
+                view=SharedContentReviewCommentsView, manager=IWidgetTitleViewletManager,
+                permission=COMMENT_CONTENT_PERMISSION)
+class SharedContentReviewAddCommentAction(JsToolbarAction):
+    """Shared content review add comment action"""
+
+    label = _("Add comment...")
+    url = 'PyAMS_content.review.addCommentAction'
+
+
+@view_config(name='get-last-review-comments.json', context=IReviewTarget, 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=IReviewTarget, 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/features/review/zmi/templates/review-add-comment.pt	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,27 @@
+<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">
+			<a class="username">
+				<tal:if condition="comment.comment_type=='request'">
+					<span class="txt-color-text nobold"
+						  i18n:translate="">Review query from</span>&nbsp;
+				</tal:if>
+				<tal:var content="request.principal.title">Owner</tal:var>
+				<tal:if condition="comment.owner in context.readers">
+					&nbsp;
+					<span class="txt-color-text nobold"
+						  i18n:translate="">(as reviewer)</span>
+				</tal:if>
+				<time class="margin-left-10" i18n:translate="">just now</time>
+			</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/features/review/zmi/templates/review-comments-json.pt	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,31 @@
+<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">
+			<a class="username">
+				<tal:if condition="comment.comment_type=='request'">
+					<span class="txt-color-text nobold"
+						  i18n:translate="">Review query from</span>&nbsp;
+				</tal:if>
+				<tal:var content="principal.title">Owner</tal:var>
+				<tal:if condition="comment.owner in context.readers">
+					&nbsp;
+					<span class="txt-color-text nobold"
+						  i18n:translate="">(as reviewer)</span>
+				</tal:if>
+				<time class="margin-left-10 hint opaque align-base"
+					  data-ams-hint-gravity="w" data-ams-hint-offset="5"
+					  tal:attributes="title view.get_date(comment)"
+					  tal:content="view.get_age(comment).lower()">age</time>
+			</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/features/review/zmi/templates/review-comments.pt	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,68 @@
+<div class="ams-widget comments" i18n:domain="pyams_content"
+	 data-ams-plugins="pyams_content"
+	 tal:attributes="data-ams-plugin-pyams_content-src extension:resource_path('pyams_content.skin:pyams_content');
+					 data-ams-plugin-pyams_content-css extension:resource_path('pyams_content.skin:pyams_content_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">
+							<a class="username">
+								<tal:if condition="comment.comment_type=='request'">
+									<span class="txt-color-text nobold"
+										  i18n:translate="">Review query from</span>&nbsp;
+								</tal:if>
+								<tal:var content="principal.title">Owner</tal:var>
+								<tal:if condition="comment.owner in context.readers">
+									&nbsp;
+									<span class="txt-color-text nobold"
+										  i18n:translate="">(as reviewer)</span>
+								</tal:if>
+								<time class="margin-left-10 hint opaque align-base"
+									  data-ams-hint-gravity="w" data-ams-hint-offset="5"
+									  tal:attributes="title view.get_date(comment)"
+									  tal:content="view.get_age(comment).lower()">age</time>
+							</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/features/review/zmi/templates/review-notification.pt	Fri Nov 10 11:46:27 2017 +0100
@@ -0,0 +1,51 @@
+<!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" />
+	<style>
+		html,
+		body,
+		p,
+		pre {
+			font-family: Ubuntu, Verdana, Arial, Helvetica, sans-serif;
+			font-size: 13px;
+			color: #333;
+		}
+		pre {
+			border-left: 2px solid #346597;
+			padding-left: 10px;
+		}
+		pre.noborder {
+			border-left: none;
+			padding-left: 0;
+		}
+	</style>
+</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>, contributor
+		of « <span i18n:name="service_name" tal:content="options['settings'].service_name">Service name</span> »
+		website, to make a review of a content.
+	</p>
+	<p i18n:translate="">
+		<span i18n:name="sender" tal:content="options['sender']">sender</span> added the following message to his
+		request:
+	</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')"
+			tal:content="i18n:context.title"></a>.</p>
+	<p i18n:translate="">After reading this content, please use the « Comments » menu entry.</p>
+	<p i18n:translate="">If you don't want to reply to this request, please contact
+		<span i18n:name="sender" tal:content="options['sender']">sender</span> directly by replying to this mail.</p>
+	<p i18n:translate="">Thank you.</p>
+	<br />
+	<pre class="noborder" tal:content="options['settings'].signature">signature</pre>
+</body>
+</html>
--- a/src/pyams_content/shared/common/zmi/review.py	Fri Oct 13 10:03:20 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,282 +0,0 @@
-#
-# 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, IWidgetTitleViewletManager
-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.viewlet.menu import MenuItem
-from pyams_skin.viewlet.toolbar import ToolbarMenuItem, JsToolbarAction
-from pyams_template.template import template_config, get_view_template
-from pyams_utils.date import get_age, format_datetime
-from pyams_utils.registry import get_utility, query_utility
-from pyams_viewlet.viewlet import viewlet_config
-from pyams_zmi.form import AdminDialogAddForm
-from pyams_zmi.view import InnerAdminView
-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)
-        if 'comment' in self.widgets:
-            self.widgets['comment'].widget_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),
-                    'events': [{
-                        'event': 'PyAMS_content.changed_item',
-                        'options': {'handler': 'PyAMS_content.review.updateComments'}
-                    }]}
-        else:
-            return {'status': 'info',
-                    'message': translate(_("Request successful. No new notification have been sent")),
-                    'events': [{
-                        'event': 'PyAMS_content.changed_item',
-                        'options': {'handler': 'PyAMS_content.review.updateComments'}
-                    }]}
-
-
-#
-# 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(InnerAdminView):
-    """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_date(self, comment):
-        return format_datetime(comment.creation_date)
-
-    def get_age(self, comment):
-        return get_age(comment.creation_date)
-
-
-@viewlet_config(name='add-review-comment.action', context=IWfSharedContent, layer=IAdminLayer,
-                view=SharedContentReviewCommentsView, manager=IWidgetTitleViewletManager,
-                permission=COMMENT_CONTENT_PERMISSION)
-class SharedContentReviewAddCommentAction(JsToolbarAction):
-    """Shared content review add comment action"""
-
-    label = _("Add comment...")
-    url = 'PyAMS_content.review.addCommentAction'
-
-
-@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)}}
--- a/src/pyams_content/shared/common/zmi/templates/review-add-comment.pt	Fri Oct 13 10:03:20 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,27 +0,0 @@
-<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">
-			<a class="username">
-				<tal:if condition="comment.comment_type=='request'">
-					<span class="txt-color-text nobold"
-						  i18n:translate="">Review query from</span>&nbsp;
-				</tal:if>
-				<tal:var content="request.principal.title">Owner</tal:var>
-				<tal:if condition="comment.owner in context.readers">
-					&nbsp;
-					<span class="txt-color-text nobold"
-						  i18n:translate="">(as reviewer)</span>
-				</tal:if>
-				<time class="margin-left-10" i18n:translate="">just now</time>
-			</a>
-			<tal:var content="structure extension:html(comment.comment)" />
-		</span>
-	</tal:var>
-</li>
--- a/src/pyams_content/shared/common/zmi/templates/review-comments-json.pt	Fri Oct 13 10:03:20 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,31 +0,0 @@
-<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">
-			<a class="username">
-				<tal:if condition="comment.comment_type=='request'">
-					<span class="txt-color-text nobold"
-						  i18n:translate="">Review query from</span>&nbsp;
-				</tal:if>
-				<tal:var content="principal.title">Owner</tal:var>
-				<tal:if condition="comment.owner in context.readers">
-					&nbsp;
-					<span class="txt-color-text nobold"
-						  i18n:translate="">(as reviewer)</span>
-				</tal:if>
-				<time class="margin-left-10 hint opaque align-base"
-					  data-ams-hint-gravity="w" data-ams-hint-offset="5"
-					  tal:attributes="title view.get_date(comment)"
-					  tal:content="view.get_age(comment).lower()">age</time>
-			</a>
-			<tal:var content="structure extension:html(comment.comment)" />
-		</span>
-	</tal:var>
-</li>
--- a/src/pyams_content/shared/common/zmi/templates/review-comments.pt	Fri Oct 13 10:03:20 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,68 +0,0 @@
-<div class="ams-widget comments" i18n:domain="pyams_content"
-	 data-ams-plugins="pyams_content"
-	 tal:attributes="data-ams-plugin-pyams_content-src extension:resource_path('pyams_content.skin:pyams_content');
-					 data-ams-plugin-pyams_content-css extension:resource_path('pyams_content.skin:pyams_content_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">
-							<a class="username">
-								<tal:if condition="comment.comment_type=='request'">
-									<span class="txt-color-text nobold"
-										  i18n:translate="">Review query from</span>&nbsp;
-								</tal:if>
-								<tal:var content="principal.title">Owner</tal:var>
-								<tal:if condition="comment.owner in context.readers">
-									&nbsp;
-									<span class="txt-color-text nobold"
-										  i18n:translate="">(as reviewer)</span>
-								</tal:if>
-								<time class="margin-left-10 hint opaque align-base"
-									  data-ams-hint-gravity="w" data-ams-hint-offset="5"
-									  tal:attributes="title view.get_date(comment)"
-									  tal:content="view.get_age(comment).lower()">age</time>
-							</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>
--- a/src/pyams_content/shared/common/zmi/templates/review-notification.pt	Fri Oct 13 10:03:20 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,51 +0,0 @@
-<!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" />
-	<style>
-		html,
-		body,
-		p,
-		pre {
-			font-family: Ubuntu, Verdana, Arial, Helvetica, sans-serif;
-			font-size: 13px;
-			color: #333;
-		}
-		pre {
-			border-left: 2px solid #346597;
-			padding-left: 10px;
-		}
-		pre.noborder {
-			border-left: none;
-			padding-left: 0;
-		}
-	</style>
-</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>, contributor
-		of « <span i18n:name="service_name" tal:content="options['settings'].service_name">Service name</span> »
-		website, to make a review of a content.
-	</p>
-	<p i18n:translate="">
-		<span i18n:name="sender" tal:content="options['sender']">sender</span> added the following message to his
-		request:
-	</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')"
-			tal:content="i18n:context.title"></a>.</p>
-	<p i18n:translate="">After reading this content, please use the « Comments » menu entry.</p>
-	<p i18n:translate="">If you don't want to reply to this request, please contact
-		<span i18n:name="sender" tal:content="options['sender']">sender</span> directly by replying to this mail.</p>
-	<p i18n:translate="">Thank you.</p>
-	<br />
-	<pre class="noborder" tal:content="options['settings'].signature">signature</pre>
-</body>
-</html>