--- /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