diff -r 000000000000 -r 7c0001cacf8e src/pyams_content/workflow/__init__.py --- /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 +# 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