src/pyams_content/workflow/__init__.py
changeset 0 7c0001cacf8e
child 10 cd69b40debd7
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/workflow/__init__.py	Thu Oct 08 13:37:29 2015 +0200
@@ -0,0 +1,567 @@
+#
+# 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
+from datetime import datetime
+
+# import interfaces
+from pyams_content.interfaces import MANAGE_SITE_ROOT_PERMISSION, MANAGE_CONTENT_PERMISSION, PUBLISH_CONTENT_PERMISSION, \
+    CREATE_CONTENT_PERMISSION
+from pyams_content.shared.common.interfaces import IWfSharedContentRoles, IManagerRestrictions
+from pyams_content.workflow.interfaces import IContentWorkflow
+from pyams_security.interfaces import IRoleProtectedObject, ISecurityManager
+from pyams_workflow.interfaces import IWorkflow, AUTOMATIC, IWorkflowPublicationInfo, SYSTEM, IWorkflowVersions, \
+    IWorkflowState, ObjectClonedEvent, IWorkflowInfo, IWorkflowStateLabel
+
+# import packages
+from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyams_utils.registry import utility_config, get_utility
+from pyams_utils.request import check_request
+from pyams_workflow.workflow import Transition, Workflow
+from pyramid.threadlocal import get_current_registry
+from zope.copy import copy
+from zope.interface import implementer
+from zope.location import locate
+from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
+
+from pyams_content import _
+
+
+#
+# Workflow states
+#
+
+DRAFT = 'draft'
+PROPOSED = 'proposed'
+CANCELED = 'canceled'
+REFUSED = 'refused'
+PUBLISHED = 'published'
+RETIRING = 'retiring'
+RETIRED = 'retired'
+ARCHIVING = 'archiving'
+ARCHIVED = 'archived'
+DELETED = 'deleted'
+
+STATES_IDS = (DRAFT,
+              PROPOSED,
+              CANCELED,
+              REFUSED,
+              PUBLISHED,
+              RETIRING,
+              RETIRED,
+              ARCHIVING,
+              ARCHIVED,
+              DELETED)
+
+UPDATE_STATES = (DRAFT, RETIRED)
+
+READONLY_STATES = (ARCHIVED, DELETED)
+
+PROTECTED_STATES = (PUBLISHED, RETIRING, ARCHIVING)
+
+MANAGER_STATES = (PROPOSED, )
+
+VISIBLE_STATES = PUBLISHED_STATES = (PUBLISHED, RETIRING)
+
+WAITING_STATES = (PROPOSED, RETIRING, ARCHIVING)
+
+RETIRED_STATES = (RETIRED, ARCHIVING)
+
+STATES_LABELS = (_("Draft"),
+                 _("Proposed"),
+                 _("Canceled"),
+                 _("Refused"),
+                 _("Published"),
+                 _("Retiring"),
+                 _("Retired"),
+                 _("Archiving"),
+                 _("Archived"),
+                 _("Deleted"))
+
+STATES_HEADERS = {DRAFT: _("draft created by {principal}"),
+                  PROPOSED: _("publication requested by {principal}"),
+                  PUBLISHED: _("published by {principal}"),
+                  RETIRING: _("retiring requested by {principal}"),
+                  RETIRED: _("retired by {principal}"),
+                  ARCHIVING: _("archiving requested by {principal}"),
+                  ARCHIVED: _("archived by {principal}")}
+
+STATES_VOCABULARY = SimpleVocabulary([SimpleTerm(STATES_IDS[i], title=t)
+                                      for i, t in enumerate(STATES_LABELS)])
+
+
+#
+# Workflow conditions
+#
+
+def can_propose_content(wf, context):
+    """Check if a content can be proposed"""
+    versions = IWorkflowVersions(context)
+    if versions.has_version(PROPOSED):
+        return False
+    request = check_request()
+    if request.has_permission(MANAGE_SITE_ROOT_PERMISSION, context):
+        return True
+    if request.principal.id in context.owner | {context.creator} | context.contributors:
+        return True
+    return False
+
+
+def can_backdraft_content(wf, context):
+    """Check if content can return to DRAFT state"""
+    return IWorkflowPublicationInfo(context).publication_date is None
+
+
+def can_retire_content(wf, context):
+    """Check if already published content can return to RETIRED state"""
+    return IWorkflowPublicationInfo(context).publication_date is not None
+
+
+def can_create_new_version(wf, context):
+    """Check if we can create a new version"""
+    versions = IWorkflowVersions(context)
+    if (versions.has_version(DRAFT) or
+        versions.has_version(PROPOSED) or
+        versions.has_version(CANCELED) or
+        versions.has_version(REFUSED)):
+        return False
+    request = check_request()
+    if request.has_permission(MANAGE_SITE_ROOT_PERMISSION, context):
+        return True
+    if request.principal.id in context.owner | {context.creator} | context.contributors:
+        return True
+    return False
+
+
+def can_delete_version(wf, context):
+    """Check if we can delete a draft version"""
+    request = check_request()
+    if request.has_permission(MANAGE_SITE_ROOT_PERMISSION, context):
+        return True
+    return request.principal.id in context.owner | {context.creator} | context.contributors
+
+
+def can_manage_content(wf, context):
+    """Check if a manager can handle content"""
+    request = check_request()
+    if request.has_permission(MANAGE_SITE_ROOT_PERMISSION, context):
+        return True
+    if request.principal.id in context.managers:
+        return True
+    restrictions = IManagerRestrictions(context).get_restrictions(request.principal.id)
+    return restrictions and restrictions.check_access(context, permission=PUBLISH_CONTENT_PERMISSION, request=request)
+
+
+def can_cancel_operation(wf, context):
+    """Check if we can cancel a request"""
+    request = check_request()
+    if request.has_permission(MANAGE_SITE_ROOT_PERMISSION, context):
+        return True
+    if request.principal.id in context.owner | {context.creator} | context.contributors:
+        return True
+    return request.principal.id == IWorkflowState(context).state_principal
+
+
+#
+# Workflow actions
+#
+
+def publish_action(wf, context):
+    """Publish version"""
+    IWorkflowPublicationInfo(context).publication_date = datetime.utcnow()
+    translate = check_request().localizer.translate
+    version_id = IWorkflowState(context).version_id
+    for version in IWorkflowVersions(context).get_versions((PUBLISHED, RETIRING, RETIRED, ARCHIVING)):
+        if version is not context:
+            IWorkflowInfo(version).fire_transition_toward('archived',
+                                                          comment=translate(_("Published version {0}")).format(version_id))
+
+
+def archive_action(wf, context):
+    """Remove readers when a content is archived"""
+    roles = IWfSharedContentRoles(context, None)
+    if roles is not None:
+        IRoleProtectedObject(context).revoke_role('pyams.Reader', roles.readers)
+
+
+def clone_action(wf, context):
+    """Create new version"""
+    result = copy(context)
+    locate(result, context.__parent__)
+    registry = get_current_registry()
+    registry.notify(ObjectClonedEvent(result, context))
+    return result
+
+
+def delete_action(wf, context):
+    """Delete draft version, and parent if single version"""
+    versions = IWorkflowVersions(context)
+    versions.remove_version(IWorkflowState(context).version_id)
+
+
+#
+# Workflow transitions
+#
+
+init = Transition(transition_id='init',
+                  title=_("Initialize"),
+                  source=None,
+                  destination=DRAFT,
+                  history_label=_("Draft creation"))
+
+draft_to_proposed = Transition(transition_id='draft_to_proposed',
+                               title=_("Propose publication"),
+                               source=DRAFT,
+                               destination=PROPOSED,
+                               permission=MANAGE_CONTENT_PERMISSION,
+                               condition=can_propose_content,
+                               menu_css_class='fa fa-fw fa-question',
+                               view_name='wf-propose.html',
+                               history_label=_("Publication request"),
+                               next_step=_("content managers authorized to take charge of your content are going to "
+                                           "be notified of your request."),
+                               order=1)
+
+retired_to_proposed = Transition(transition_id='retired_to_proposed',
+                                 title=_("Propose publication"),
+                                 source=RETIRED,
+                                 destination=PROPOSED,
+                                 permission=MANAGE_CONTENT_PERMISSION,
+                                 condition=can_propose_content,
+                                 menu_css_class='fa fa-fw fa-question',
+                                 view_name='wf-propose.html',
+                                 history_label=_("Publication request"),
+                                 next_step=_("content managers authorized to take charge of your content are going to "
+                                             "be notified of your request."),
+                                 order=1)
+
+proposed_to_canceled = Transition(transition_id='proposed_to_canceled',
+                                  title=_("Cancel publication request"),
+                                  source=PROPOSED,
+                                  destination=CANCELED,
+                                  permission=MANAGE_CONTENT_PERMISSION,
+                                  condition=can_cancel_operation,
+                                  menu_css_class='fa fa-fw fa-mail-reply',
+                                  view_name='wf-cancel-propose.html',
+                                  history_label=_("Publication request canceled"),
+                                  order=2)
+
+canceled_to_draft = Transition(transition_id='canceled_to_draft',
+                               title=_("Reset canceled publication to draft"),
+                               source=CANCELED,
+                               destination=DRAFT,
+                               trigger=AUTOMATIC,
+                               history_label=_("State reset to 'draft' (automatic)"),
+                               condition=can_backdraft_content)
+
+canceled_to_retired = Transition(transition_id='canceled_to_retired',
+                                 title=_("Reset canceled publication to retired"),
+                                 source=CANCELED,
+                                 destination=RETIRED,
+                                 trigger=AUTOMATIC,
+                                 history_label=_("State reset to 'retired' (automatic)"),
+                                 condition=can_retire_content)
+
+proposed_to_refused = Transition(transition_id='proposed_to_refused',
+                                 title=_("Refuse publication"),
+                                 source=PROPOSED,
+                                 destination=REFUSED,
+                                 permission=PUBLISH_CONTENT_PERMISSION,
+                                 condition=can_manage_content,
+                                 menu_css_class='fa fa-fw fa-thumbs-o-down',
+                                 view_name='wf-refuse.html',
+                                 history_label=_("Publication refused"),
+                                 order=3)
+
+refused_to_draft = Transition(transition_id='refused_to_draft',
+                              title=_("Reset refused publication to draft"),
+                              source=REFUSED,
+                              destination=DRAFT,
+                              trigger=AUTOMATIC,
+                              history_label=_("State reset to 'draft' (automatic)"),
+                              condition=can_backdraft_content)
+
+refused_to_retired = Transition(transition_id='refused_to_retired',
+                                title=_("Reset refused publication to retired"),
+                                source=REFUSED,
+                                destination=RETIRED,
+                                trigger=AUTOMATIC,
+                                history_label=_("State reset to 'refused' (automatic)"),
+                                condition=can_retire_content)
+
+proposed_to_published = Transition(transition_id='proposed_to_published',
+                                   title=_("Publish content"),
+                                   source=PROPOSED,
+                                   destination=PUBLISHED,
+                                   permission=PUBLISH_CONTENT_PERMISSION,
+                                   condition=can_manage_content,
+                                   action=publish_action,
+                                   menu_css_class='fa fa-fw fa-thumbs-o-up',
+                                   view_name='wf-publish.html',
+                                   history_label=_("Content published"),
+                                   order=4)
+
+published_to_retiring = Transition(transition_id='published_to_retiring',
+                                   title=_("Request retiring"),
+                                   source=PUBLISHED,
+                                   destination=RETIRING,
+                                   permission=MANAGE_CONTENT_PERMISSION,
+                                   menu_css_class='fa fa-fw fa-pause',
+                                   view_name='wf-retiring.html',
+                                   history_label=_("Retire request"),
+                                   next_step=_("content managers authorized to take charge of your content are going "
+                                               "to be notified of your request."),
+                                   order=7)
+
+retiring_to_published = Transition(transition_id='retiring_to_published',
+                                   title=_("Cancel retiring request"),
+                                   source=RETIRING,
+                                   destination=PUBLISHED,
+                                   permission=MANAGE_CONTENT_PERMISSION,
+                                   condition=can_cancel_operation,
+                                   menu_css_class='fa fa-fw fa-mail-reply',
+                                   view_name='wf-cancel-retiring.html',
+                                   history_label=_("Retire request canceled"),
+                                   order=8)
+
+retiring_to_retired = Transition(transition_id='retiring_to_retired',
+                                 title=_("Retire content"),
+                                 source=RETIRING,
+                                 destination=RETIRED,
+                                 permission=PUBLISH_CONTENT_PERMISSION,
+                                 condition=can_manage_content,
+                                 menu_css_class='fa fa-fw fa-stop',
+                                 view_name='wf-retire.html',
+                                 history_label=_("Content retired"),
+                                 order=9)
+
+retired_to_archiving = Transition(transition_id='retired_to_archiving',
+                                  title=_("Request archive"),
+                                  source=RETIRED,
+                                  destination=ARCHIVING,
+                                  permission=MANAGE_CONTENT_PERMISSION,
+                                  menu_css_class='fa fa-fw fa-archive',
+                                  view_name='wf-archiving.html',
+                                  history_label=_("Archive request"),
+                                  next_step=_("content managers authorized to take charge of your content are going to "
+                                              "be notified of your request."),
+                                  order=10)
+
+archiving_to_retired = Transition(transition_id='archiving_to_retired',
+                                  title=_("Cancel archiving request"),
+                                  source=ARCHIVING,
+                                  destination=RETIRED,
+                                  permission=MANAGE_CONTENT_PERMISSION,
+                                  condition=can_cancel_operation,
+                                  menu_css_class='fa fa-fw fa-mail-reply',
+                                  view_name='wf-cancel-archiving.html',
+                                  history_label=_("Archive request canceled"),
+                                  order=11)
+
+archiving_to_archived = Transition(transition_id='archiving_to_archived',
+                                   title=_("Archive content"),
+                                   source=ARCHIVING,
+                                   destination=ARCHIVED,
+                                   permission=PUBLISH_CONTENT_PERMISSION,
+                                   condition=can_manage_content,
+                                   action=archive_action,
+                                   menu_css_class='fa fa-fw fa-archive',
+                                   view_name='wf-archive.html',
+                                   history_label=_("Content archived"),
+                                   order=12)
+
+published_to_archived = Transition(transition_id='published_to_archived',
+                                   title=_("Archive published content"),
+                                   source=PUBLISHED,
+                                   destination=ARCHIVED,
+                                   trigger=SYSTEM,
+                                   history_label=_("Content archived after version publication"),
+                                   action=archive_action)
+
+retiring_to_archived = Transition(transition_id='retiring_to_archived',
+                                  title=_("Archive retiring content"),
+                                  source=RETIRING,
+                                  destination=ARCHIVED,
+                                  trigger=SYSTEM,
+                                  history_label=_("Content archived after version publication"),
+                                  action=archive_action)
+
+retired_to_archived = Transition(transition_id='retired_to_archived',
+                                 title=_("Archive retired content"),
+                                 source=RETIRED,
+                                 destination=ARCHIVED,
+                                 trigger=SYSTEM,
+                                 history_label=_("Content archived after version publication"),
+                                 action=archive_action)
+
+published_to_draft = Transition(transition_id='published_to_draft',
+                                title=_("Create new version"),
+                                source=PUBLISHED,
+                                destination=DRAFT,
+                                permission=CREATE_CONTENT_PERMISSION,
+                                condition=can_create_new_version,
+                                action=clone_action,
+                                menu_css_class='fa fa-fw fa-file-o',
+                                view_name='wf-clone.html',
+                                history_label=_("New version created"),
+                                order=13)
+
+retiring_to_draft = Transition(transition_id='retiring_to_draft',
+                               title=_("Create new version"),
+                               source=RETIRING,
+                               destination=DRAFT,
+                               permission=CREATE_CONTENT_PERMISSION,
+                               condition=can_create_new_version,
+                               action=clone_action,
+                               menu_css_class='fa fa-fw fa-file-o',
+                               view_name='wf-clone.html',
+                               history_label=_("New version created"),
+                               order=14)
+
+retired_to_draft = Transition(transition_id='retired_to_draft',
+                              title=_("Create new version"),
+                              source=RETIRED,
+                              destination=DRAFT,
+                              permission=CREATE_CONTENT_PERMISSION,
+                              condition=can_create_new_version,
+                              action=clone_action,
+                              menu_css_class='fa fa-fw fa-file-o',
+                              view_name='wf-clone.html',
+                              history_label=_("New version created"),
+                              order=15)
+
+archiving_to_draft = Transition(transition_id='archiving_to_draft',
+                                title=_("Create new version"),
+                                source=ARCHIVING,
+                                destination=DRAFT,
+                                permission=CREATE_CONTENT_PERMISSION,
+                                condition=can_create_new_version,
+                                action=clone_action,
+                                menu_css_class='fa fa-fw fa-file-o',
+                                view_name='wf-clone.html',
+                                history_label=_("New version created"),
+                                order=16)
+
+archived_to_draft = Transition(transition_id='archived_to_draft',
+                               title=_("Create new version"),
+                               source=ARCHIVED,
+                               destination=DRAFT,
+                               permission=CREATE_CONTENT_PERMISSION,
+                               condition=can_create_new_version,
+                               action=clone_action,
+                               menu_css_class='fa fa-fw fa-file-o',
+                               view_name='wf-clone.html',
+                               history_label=_("New version created"),
+                               order=17)
+
+delete = Transition(transition_id='delete',
+                    title=_("Delete version"),
+                    source=DRAFT,
+                    destination=DELETED,
+                    permission=MANAGE_CONTENT_PERMISSION,
+                    condition=can_delete_version,
+                    action=delete_action,
+                    menu_css_class='fa fa-fw fa-trash',
+                    view_name='wf-delete.html',
+                    history_label=_("Version deleted"),
+                    order=18)
+
+wf_transitions = [init,
+                  draft_to_proposed,
+                  retired_to_proposed,
+                  proposed_to_canceled,
+                  canceled_to_draft,
+                  canceled_to_retired,
+                  proposed_to_refused,
+                  refused_to_draft,
+                  refused_to_retired,
+                  proposed_to_published,
+                  published_to_retiring,
+                  retiring_to_published,
+                  retiring_to_retired,
+                  retired_to_archiving,
+                  archiving_to_retired,
+                  published_to_archived,
+                  retiring_to_archived,
+                  retired_to_archived,
+                  archiving_to_archived,
+                  published_to_draft,
+                  retiring_to_draft,
+                  retired_to_draft,
+                  archiving_to_draft,
+                  archived_to_draft,
+                  delete]
+
+
+@implementer(IContentWorkflow)
+class ContentWorkflow(Workflow):
+    """PyAMS default content workflow"""
+
+
+@adapter_config(context=IContentWorkflow, provides=IWorkflowStateLabel)
+class WorkflowStateLabelAdapter(ContextAdapter):
+    """Generic state label adapter"""
+
+    @staticmethod
+    def get_label(content, request=None, format=True):
+        if request is None:
+            request = check_request()
+        translate = request.localizer.translate
+        security = get_utility(ISecurityManager)
+        state = IWorkflowState(content)
+        state_label = translate(STATES_HEADERS[state.state])
+        if format:
+            state_label = state_label.format(principal=security.get_principal(state.state_principal).title)
+        return state_label
+
+
+@adapter_config(name=DRAFT, context=IContentWorkflow, provides=IWorkflowStateLabel)
+class DraftWorkflowStateLabelAdapter(ContextAdapter):
+    """Draft state label adapter"""
+
+    @staticmethod
+    def get_label(content, request=None, format=True):
+        if request is None:
+            request = check_request()
+        translate = request.localizer.translate
+        security = get_utility(ISecurityManager)
+        state = IWorkflowState(content)
+        if len(state.history) == 1:
+            state_label = translate(STATES_HEADERS[state.state])
+        else:
+            state_label = translate(_('publication refused by {principal}'))
+        if format:
+            state_label = state_label.format(principal=security.get_principal(state.state_principal).title)
+        return state_label
+
+
+wf = ContentWorkflow(wf_transitions,
+                     states=STATES_VOCABULARY,
+                     initial_state=DRAFT,
+                     update_states=UPDATE_STATES,
+                     readonly_states=READONLY_STATES,
+                     protected_states=PROTECTED_STATES,
+                     manager_states=MANAGER_STATES,
+                     published_states=VISIBLE_STATES,
+                     waiting_states=WAITING_STATES,
+                     retired_states=RETIRED_STATES)
+
+
+@utility_config(name='PyAMS default workflow', provides=IWorkflow)
+class WorkflowUtility(object):
+    """PyAMS default workflow utility"""
+
+    def __new__(cls):
+        return wf