Updated workflow management to handle content's "pre-publication"
authorThierry Florac <thierry.florac@onf.fr>
Thu, 10 Jan 2019 17:37:38 +0100
changeset 1222 482e0e1f192f
parent 1221 0e72ad321c13
child 1223 99a4c33e2962
Updated workflow management to handle content's "pre-publication"
src/pyams_content/shared/common/zmi/templates/wf-cancel-publish-message.pt
src/pyams_content/shared/common/zmi/workflow.py
src/pyams_content/workflow/__init__.py
src/pyams_content/workflow/basic.py
src/pyams_content/workflow/interfaces.py
src/pyams_content/workflow/task.py
src/pyams_content/workflow/zmi/__init__.py
src/pyams_content/workflow/zmi/task.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/common/zmi/templates/wf-cancel-publish-message.pt	Thu Jan 10 17:37:38 2019 +0100
@@ -0,0 +1,3 @@
+<p class="alert alert-info padding-5 margin-bottom-5" i18n:domain="pyams_content" i18n:translate="">
+	After cancelling the publication, the content will return to proposed publication state.
+</p>
--- a/src/pyams_content/shared/common/zmi/workflow.py	Wed Jan 09 12:21:13 2019 +0100
+++ b/src/pyams_content/shared/common/zmi/workflow.py	Thu Jan 10 17:37:38 2019 +0100
@@ -9,6 +9,13 @@
 # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
 # FOR A PARTICULAR PURPOSE.
 #
+from zope.intid import IIntIds
+from zope.lifecycleevent import ObjectModifiedEvent
+
+from pyams_content.workflow.task import ContentPublishingTask
+from pyams_scheduler.interfaces import IScheduler, IDateTaskScheduling
+from pyams_sequence.interfaces import ISequentialIdInfo
+
 
 __docformat__ = 'restructuredtext'
 
@@ -38,15 +45,15 @@
 from pyams_template.template import template_config
 from pyams_utils.adapter import adapter_config
 from pyams_utils.date import format_datetime
-from pyams_utils.registry import get_utility
+from pyams_utils.registry import get_utility, query_utility
 from pyams_utils.text import text_to_html
-from pyams_utils.timezone import tztime
+from pyams_utils.timezone import tztime, gmtime
 from pyams_utils.traversing import get_parent
 from pyams_utils.url import absolute_url
 from pyams_viewlet.viewlet import Viewlet, viewlet_config
 from pyams_workflow.interfaces import IWorkflow, IWorkflowCommentInfo, IWorkflowInfo, IWorkflowPublicationInfo, \
     IWorkflowRequestUrgencyInfo, IWorkflowState, IWorkflowStateLabel, IWorkflowTransitionInfo, IWorkflowVersions, \
-    MANUAL, SYSTEM
+    MANUAL, SYSTEM, NoTransitionAvailableError, AmbiguousTransitionError
 from pyams_workflow.zmi.transition import WorkflowContentTransitionForm
 from pyams_zmi.form import InnerAdminAddForm
 
@@ -350,7 +357,21 @@
         pub_info.publication_expiration_date = data.get('publication_expiration_date')
         if 'displayed_publication_date' in data:
             pub_info.displayed_publication_date = data.get('displayed_publication_date')
-        return super(PublicationForm, self).createAndAdd(data)
+        now = gmtime(datetime.utcnow())
+        if pub_info.publication_effective_date <= now:
+            # immediate publication
+            return super(PublicationForm, self).createAndAdd(data)
+        else:
+            # delayed publication: we schedule a publication task
+            transition = self.transition.user_data.get('prepared_transition')
+            if transition is None:
+                raise AmbiguousTransitionError("This workflow doesn't support pre-publication!")
+            info = IWorkflowInfo(self.context)
+            info.fire_transition(transition.transition_id, comment=data.get('comment'))
+            info.fire_automatic()
+            IWorkflowState(self.context).state_urgency = data.get('urgent_request') or False
+            self.request.registry.notify(ObjectModifiedEvent(self.context))
+            return info
 
 
 @subscriber(IDataExtractedEvent, form_selector=PublicationForm)
@@ -382,6 +403,61 @@
 
 
 #
+# Pre-publication cancel form
+#
+
+class IPublicationCancelButtons(Interface):
+    """Shared content publication cancel buttons"""
+
+    close = CloseButton(name='close', title=_("Cancel"))
+    action = button.Button(name='action', title=_("Cancel publication"))
+
+
+@pagelet_config(name='wf-cancel-publish.html', context=IWfSharedContent, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+@ajax_config(name='wf-cancel-publish.json', context=IWfSharedContent, layer=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, base=AJAXAddForm)
+class PublicationCancelForm(WorkflowContentTransitionForm):
+    """Shared content publication cancel form"""
+
+    fields = field.Fields(IWorkflowTransitionInfo) + \
+             field.Fields(IWorkflowCommentInfo)
+    buttons = button.Buttons(IPublicationCancelButtons)
+
+    def updateWidgets(self, prefix=None):
+        super(PublicationCancelForm, self).updateWidgets(prefix)
+        if 'comment' in self.widgets:
+            self.widgets['comment'].required = True
+
+
+@subscriber(IDataExtractedEvent, form_selector=PublicationCancelForm)
+def handle_publication_cancel_form_data_extraction(event):
+    """Handle publication cancel form data extraction"""
+    comment = (event.data.get('comment') or '').strip()
+    if not comment:
+        event.form.widgets.errors += (Invalid(_("A comment is required")),)
+
+
+@viewlet_config(name='wf-cancel-message', context=IWfSharedContent, layer=IPyAMSLayer,
+                view=PublicationCancelForm, manager=IWidgetsPrefixViewletsManager, weight=20)
+@template_config(template='templates/wf-cancel-publish-message.pt')
+class PublicationCancelFormMessage(Viewlet):
+    """Publication cancel form info message"""
+
+
+@viewlet_config(name='wf-cancel-owner-warning', context=IWfSharedContent, layer=IPyAMSLayer,
+                view=PublicationCancelForm, manager=IWidgetsPrefixViewletsManager, weight=30)
+@template_config(template='templates/wf-owner-warning.pt')
+class PublicationCancelFormWarning(Viewlet):
+    """Publication cancel form warning message"""
+
+    def __new__(cls, context, request, view, manager):
+        if request.principal.id in context.owner:
+            return None
+        return Viewlet.__new__(cls)
+
+
+#
 # Publication retire request form
 #
 
--- a/src/pyams_content/workflow/__init__.py	Wed Jan 09 12:21:13 2019 +0100
+++ b/src/pyams_content/workflow/__init__.py	Thu Jan 10 17:37:38 2019 +0100
@@ -17,6 +17,7 @@
 from pyramid.threadlocal import get_current_registry
 from zope.copy import copy
 from zope.interface import implementer
+from zope.intid import IIntIds
 from zope.location import locate
 from zope.schema.vocabulary import SimpleTerm, SimpleVocabulary
 
@@ -25,10 +26,13 @@
 from pyams_content.interfaces import MANAGER_ROLE, OWNER_ROLE, PILOT_ROLE, READER_ROLE, WEBMASTER_ROLE
 from pyams_content.shared.common.interfaces import IManagerRestrictions, IWfSharedContentRoles
 from pyams_content.workflow.interfaces import IContentWorkflow
+from pyams_content.workflow.task import ContentPublishingTask, ContentArchivingTask
+from pyams_scheduler.interfaces import IDateTaskScheduling, IScheduler
 from pyams_security.interfaces import IRoleProtectedObject
+from pyams_sequence.interfaces import ISequentialIdInfo
 from pyams_utils.adapter import ContextAdapter, adapter_config
 from pyams_utils.date import format_datetime
-from pyams_utils.registry import utility_config
+from pyams_utils.registry import get_utility, query_utility, utility_config
 from pyams_utils.request import check_request
 from pyams_workflow.interfaces import AUTOMATIC, IWorkflow, IWorkflowInfo, IWorkflowPublicationInfo, IWorkflowState, \
     IWorkflowStateLabel, IWorkflowVersions, ObjectClonedEvent, SYSTEM
@@ -36,7 +40,6 @@
 
 from pyams_content import _
 
-
 #
 # Workflow states
 #
@@ -45,6 +48,7 @@
 PROPOSED = 'proposed'
 CANCELED = 'canceled'
 REFUSED = 'refused'
+PRE_PUBLISHED = 'pre-published'
 PUBLISHED = 'published'
 RETIRING = 'retiring'
 RETIRED = 'retired'
@@ -56,6 +60,7 @@
               PROPOSED,
               CANCELED,
               REFUSED,
+              PRE_PUBLISHED,
               PUBLISHED,
               RETIRING,
               RETIRED,
@@ -67,6 +72,7 @@
                  _("Proposed"),
                  _("Canceled"),
                  _("Refused"),
+                 _("Published (waiting)"),
                  _("Published"),
                  _("Retiring"),
                  _("Retired"),
@@ -77,13 +83,16 @@
 STATES_VOCABULARY = SimpleVocabulary([SimpleTerm(STATES_IDS[i], title=t)
                                       for i, t in enumerate(STATES_LABELS)])
 
-STATES_HEADERS = {DRAFT: _("draft created"),
-                  PROPOSED: _("publication requested"),
-                  PUBLISHED: _("published"),
-                  RETIRING: _("retiring requested"),
-                  RETIRED: _("retired"),
-                  ARCHIVING: _("archiving requested"),
-                  ARCHIVED: _("archived")}
+STATES_HEADERS = {
+    DRAFT: _("draft created"),
+    PROPOSED: _("publication requested"),
+    PRE_PUBLISHED: _("published (waiting)"),
+    PUBLISHED: _("published"),
+    RETIRING: _("retiring requested"),
+    RETIRED: _("retired"),
+    ARCHIVING: _("archiving requested"),
+    ARCHIVED: _("archived")
+}
 
 
 UPDATE_STATES = (DRAFT, )
@@ -92,13 +101,15 @@
 READONLY_STATES = (RETIRED, ARCHIVING, ARCHIVED, DELETED)
 '''Retired and archived contents can't be modified'''
 
-PROTECTED_STATES = (PUBLISHED, RETIRING)
+PROTECTED_STATES = (PRE_PUBLISHED, PUBLISHED, RETIRING)
 '''Protected states are available to webmasters in update mode'''
 
 MANAGER_STATES = (PROPOSED, )
 '''Only managers can update proposed contents (if their restrictions apply)'''
 
-VISIBLE_STATES = PUBLISHED_STATES = (PUBLISHED, RETIRING)
+PUBLISHED_STATES = (PRE_PUBLISHED, PUBLISHED, RETIRING)
+
+VISIBLE_STATES = (PUBLISHED, RETIRING)
 
 WAITING_STATES = (PROPOSED, RETIRING, ARCHIVING)
 
@@ -150,7 +161,8 @@
     if (versions.has_version(DRAFT) or
             versions.has_version(PROPOSED) or
             versions.has_version(CANCELED) or
-            versions.has_version(REFUSED)):
+            versions.has_version(REFUSED) or
+            versions.has_version(PRE_PUBLISHED)):
         return False
     request = check_request()
     # grant access to webmaster
@@ -231,6 +243,40 @@
     IWorkflowPublicationInfo(context).reset(complete=True)
 
 
+def prepublish_action(wf, context):
+    """Publish content with a future effective publication date
+
+    We create a dedicated publication task which will effectively publish the content
+    """
+    scheduler = query_utility(IScheduler)
+    if scheduler is not None:
+        intids = get_utility(IIntIds)
+        context_id = intids.queryId(context)
+        task_id = 'workflow::{}'.format(context_id)
+        if task_id in scheduler:
+            del scheduler[task_id]
+        task = ContentPublishingTask(context_id,
+                                     prepublished_to_published.transition_id)
+        task.name = 'Planned publication for {}'.format(ISequentialIdInfo(context).public_oid)
+        task.schedule_mode = 'Date-style scheduling'
+        pub_info = IWorkflowPublicationInfo(context)
+        schedule_info = IDateTaskScheduling(task)
+        schedule_info.active = True
+        schedule_info.start_date = pub_info.publication_effective_date
+        scheduler[task_id] = task
+
+
+def cancel_prepublish_action(wf, context):
+    """Cancel pre-publication"""
+    scheduler = query_utility(IScheduler)
+    if scheduler is not None:
+        intids = get_utility(IIntIds)
+        context_id = intids.queryId(context)
+        task_id = 'workflow::{}'.format(context_id)
+        if task_id in scheduler:
+            del scheduler[task_id]
+
+
 def publish_action(wf, context):
     """Publish version"""
     request = check_request()
@@ -239,18 +285,44 @@
     publication_info.publication_date = datetime.utcnow()
     publication_info.publisher = request.principal.id
     version_id = IWorkflowState(context).version_id
-    for version in IWorkflowVersions(context).get_versions((PUBLISHED, RETIRING, RETIRED, ARCHIVING)):
+    for version in IWorkflowVersions(context).get_versions((PRE_PUBLISHED, PUBLISHED, RETIRING, RETIRED, ARCHIVING)):
         if version is not context:
             IWorkflowInfo(version).fire_transition_toward(ARCHIVED,
                                                           comment=translate(_("Published version {0}")).format(
                                                               version_id))
+    # check expiration date and create auto-archiving task if needed
+    if publication_info.publication_expiration_date:
+        scheduler = query_utility(IScheduler)
+        if scheduler is not None:
+            intids = get_utility(IIntIds)
+            context_id = intids.queryId(context)
+            task_id = 'workflow::{}'.format(context_id)
+            if task_id in scheduler:
+                del scheduler[task_id]
+            task = ContentArchivingTask(context_id)
+            task.name = 'Planned archiving for {}'.format(ISequentialIdInfo(context).public_oid)
+            task.schedule_mode = 'Date-style scheduling'
+            pub_info = IWorkflowPublicationInfo(context)
+            schedule_info = IDateTaskScheduling(task)
+            schedule_info.active = True
+            schedule_info.start_date = pub_info.publication_expiration_date
+            scheduler[task_id] = task
 
 
 def archive_action(wf, context):
-    """Remove readers when a content is archived"""
+    """Remove readers when a content is archived, and delete any scheduler task"""
+    # remove readers
     roles = IWfSharedContentRoles(context, None)
     if roles is not None:
         IRoleProtectedObject(context).revoke_role(READER_ROLE, roles.readers)
+    # remove any scheduler task
+    scheduler = query_utility(IScheduler)
+    if scheduler is not None:
+        intids = get_utility(IIntIds)
+        context_id = intids.queryId(context)
+        task_id = 'workflow::{}'.format(context_id)
+        if task_id in scheduler:
+            del scheduler[task_id]
 
 
 def clone_action(wf, context):
@@ -368,6 +440,38 @@
                                 history_label=_("State reset to 'refused' (automatic)"),
                                 condition=can_retire_content)
 
+proposed_to_prepublished = Transition(transition_id='proposed_to_prepublished',
+                                      title=_("Pre-publish content"),
+                                      source=PROPOSED,
+                                      destination=PRE_PUBLISHED,
+                                      trigger=SYSTEM,
+                                      action=prepublish_action,
+                                      history_label=_("Content pre-published"),
+                                      notify_roles={'*'},
+                                      notify_message=_("pre-published the content « {0} »"))
+
+prepublished_to_published = Transition(transition_id='prepublished_to_published',
+                                       title=_("Publish content"),
+                                       source=PRE_PUBLISHED,
+                                       destination=PUBLISHED,
+                                       trigger=SYSTEM,
+                                       action=publish_action,
+                                       history_label=_("Content published"))
+
+prepublished_to_proposed = Transition(transition_id='prepublished_to_proposed',
+                                      title=_("Cancel publication"),
+                                      source=PRE_PUBLISHED,
+                                      destination=PROPOSED,
+                                      permission=MANAGE_CONTENT_PERMISSION,
+                                      condition=can_manage_content,
+                                      action=cancel_prepublish_action,
+                                      menu_css_class='fa fa-fw fa-mail-reply',
+                                      view_name='wf-cancel-publish.html',
+                                      history_label=_("Publication canceled"),
+                                      notify_roles={WEBMASTER_ROLE, PILOT_ROLE, MANAGER_ROLE, OWNER_ROLE},
+                                      notify_message=_("cancelled the publication for content « {0} »"),
+                                      order=1)
+
 proposed_to_published = Transition(transition_id='proposed_to_published',
                                    title=_("Publish content"),
                                    source=PROPOSED,
@@ -375,6 +479,7 @@
                                    permission=PUBLISH_CONTENT_PERMISSION,
                                    condition=can_manage_content,
                                    action=publish_action,
+                                   prepared_transition=proposed_to_prepublished,
                                    menu_css_class='fa fa-fw fa-thumbs-o-up',
                                    view_name='wf-publish.html',
                                    history_label=_("Content published"),
@@ -575,6 +680,9 @@
                   proposed_to_refused,
                   refused_to_draft,
                   refused_to_retired,
+                  proposed_to_prepublished,
+                  prepublished_to_published,
+                  prepublished_to_proposed,
                   proposed_to_published,
                   published_to_retiring,
                   published_to_retired,
@@ -654,7 +762,8 @@
                      readonly_states=READONLY_STATES,
                      protected_states=PROTECTED_STATES,
                      manager_states=MANAGER_STATES,
-                     published_states=VISIBLE_STATES,
+                     published_states=PUBLISHED_STATES,
+                     visible_states=VISIBLE_STATES,
                      waiting_states=WAITING_STATES,
                      retired_states=RETIRED_STATES,
                      archived_states=ARCHIVED_STATES,
--- a/src/pyams_content/workflow/basic.py	Wed Jan 09 12:21:13 2019 +0100
+++ b/src/pyams_content/workflow/basic.py	Thu Jan 10 17:37:38 2019 +0100
@@ -16,38 +16,45 @@
 
 from zope.copy import copy
 from zope.interface import implementer
+from zope.intid import IIntIds
 from zope.location import locate
 from zope.schema.vocabulary import SimpleTerm, SimpleVocabulary
 
-from pyams_content.interfaces import CREATE_CONTENT_PERMISSION, CREATE_VERSION_PERMISSION, MANAGER_ROLE, \
+from pyams_content.interfaces import CREATE_VERSION_PERMISSION, MANAGER_ROLE, \
     MANAGE_CONTENT_PERMISSION, MANAGE_SITE_ROOT_PERMISSION, OWNER_ROLE, PILOT_ROLE, PUBLISH_CONTENT_PERMISSION, \
     READER_ROLE, WEBMASTER_ROLE
 from pyams_content.shared.common.interfaces import IManagerRestrictions
 from pyams_content.shared.common.interfaces import IWfSharedContentRoles
+from pyams_content.workflow import ContentArchivingTask, ContentPublishingTask
 from pyams_content.workflow.interfaces import IBasicWorkflow
+from pyams_scheduler.interfaces import IDateTaskScheduling, IScheduler
 from pyams_security.interfaces import IRoleProtectedObject
+from pyams_sequence.interfaces import ISequentialIdInfo
 from pyams_utils.adapter import ContextAdapter, adapter_config
 from pyams_utils.date import format_datetime
-from pyams_utils.registry import get_current_registry, utility_config
+from pyams_utils.registry import get_current_registry, get_utility, query_utility, utility_config
 from pyams_utils.request import check_request
 from pyams_workflow.interfaces import IWorkflow, IWorkflowInfo, IWorkflowPublicationInfo, IWorkflowState, \
-    IWorkflowStateLabel, IWorkflowVersions, ObjectClonedEvent
+    IWorkflowStateLabel, IWorkflowVersions, ObjectClonedEvent, SYSTEM
 from pyams_workflow.workflow import Transition, Workflow
 
 from pyams_content import _
 
 
 DRAFT = 'draft'
+PRE_PUBLISHED = 'pre-published'
 PUBLISHED = 'published'
 ARCHIVED = 'archived'
 DELETED = 'deleted'
 
 STATES_IDS = (DRAFT,
+              PRE_PUBLISHED,
               PUBLISHED,
               ARCHIVED,
               DELETED)
 
 STATES_LABELS = (_("Draft"),
+                 _("Published (waiting)"),
                  _("Published"),
                  _("Archived"),
                  _("Deleted"))
@@ -55,9 +62,12 @@
 STATES_VOCABULARY = SimpleVocabulary([SimpleTerm(STATES_IDS[i], title=t)
                                       for i, t in enumerate(STATES_LABELS)])
 
-STATES_HEADERS = {DRAFT: _("draft created"),
-                  PUBLISHED: _("published"),
-                  ARCHIVED: _("archived")}
+STATES_HEADERS = {
+    DRAFT: _("draft created"),
+    PRE_PUBLISHED: _("published (waiting)"),
+    PUBLISHED: _("published"),
+    ARCHIVED: _("archived")
+}
 
 UPDATE_STATES = (DRAFT, )
 '''Default state available to contributors in update mode'''
@@ -65,13 +75,17 @@
 READONLY_STATES = (ARCHIVED, DELETED)
 '''Retired and archived contents can't be modified'''
 
-PROTECTED_STATES = (PUBLISHED, )
+PROTECTED_STATES = (PRE_PUBLISHED, PUBLISHED, )
 '''Protected states are available to webmasters in update mode'''
 
 MANAGER_STATES = ()
 '''No custom state available to managers!'''
 
-VISIBLE_STATES = PUBLISHED_STATES = (PUBLISHED, )
+PUBLISHED_STATES = (PRE_PUBLISHED, PUBLISHED)
+'''Pre-published and published states are marked as published'''
+
+VISIBLE_STATES = (PUBLISHED, )
+'''Only published state is visible in front-office'''
 
 WAITING_STATES = ()
 
@@ -103,7 +117,7 @@
     """Check if we can create a new version"""
     # can't create new version when previous draft already exists
     versions = IWorkflowVersions(context)
-    if versions.has_version(DRAFT):
+    if versions.has_version(DRAFT) or versions.has_version(PRE_PUBLISHED):
         return False
     request = check_request()
     # grant access to webmaster
@@ -143,6 +157,40 @@
 # Workflow actions
 #
 
+def prepublish_action(wf, context):
+    """Publish content with a future effective publication date
+
+    We create a dedicated publication task which will effectively publish the content
+    """
+    scheduler = query_utility(IScheduler)
+    if scheduler is not None:
+        intids = get_utility(IIntIds)
+        context_id = intids.queryId(context)
+        task_id = 'workflow::{}'.format(context_id)
+        if task_id in scheduler:
+            del scheduler[task_id]
+        task = ContentPublishingTask(context_id,
+                                     prepublished_to_published.transition_id)
+        task.name = 'Planned publication for {}'.format(ISequentialIdInfo(context).public_oid)
+        task.schedule_mode = 'Date-style scheduling'
+        pub_info = IWorkflowPublicationInfo(context)
+        schedule_info = IDateTaskScheduling(task)
+        schedule_info.active = True
+        schedule_info.start_date = pub_info.publication_effective_date
+        scheduler[task_id] = task
+
+
+def cancel_prepublish_action(wf, context):
+    """Cancel pre-publication"""
+    scheduler = query_utility(IScheduler)
+    if scheduler is not None:
+        intids = get_utility(IIntIds)
+        context_id = intids.queryId(context)
+        task_id = 'workflow::{}'.format(context_id)
+        if task_id in scheduler:
+            del scheduler[task_id]
+
+
 def publish_action(wf, context):
     """Publish version"""
     request = check_request()
@@ -156,13 +204,39 @@
             IWorkflowInfo(version).fire_transition_toward(ARCHIVED,
                                                           comment=translate(_("Published version {0}")).format(
                                                               version_id))
+    # check expiration date and create auto-archiving task if needed
+    if publication_info.publication_expiration_date:
+        scheduler = query_utility(IScheduler)
+        if scheduler is not None:
+            intids = get_utility(IIntIds)
+            context_id = intids.queryId(context)
+            task_id = 'workflow::{}'.format(context_id)
+            if task_id in scheduler:
+                del scheduler[task_id]
+            task = ContentArchivingTask(context_id)
+            task.name = 'Planned archiving for {}'.format(ISequentialIdInfo(context).public_oid)
+            task.schedule_mode = 'Date-style scheduling'
+            pub_info = IWorkflowPublicationInfo(context)
+            schedule_info = IDateTaskScheduling(task)
+            schedule_info.active = True
+            schedule_info.start_date = pub_info.publication_expiration_date
+            scheduler[task_id] = task
 
 
 def archive_action(wf, context):
-    """Remove readers when a content is archived"""
+    """Remove readers when a content is archived, and delete any scheduler task"""
+    # remove readers
     roles = IWfSharedContentRoles(context, None)
     if roles is not None:
         IRoleProtectedObject(context).revoke_role(READER_ROLE, roles.readers)
+    # remove any scheduler task
+    scheduler = query_utility(IScheduler)
+    if scheduler is not None:
+        intids = get_utility(IIntIds)
+        context_id = intids.queryId(context)
+        task_id = 'workflow::{}'.format(context_id)
+        if task_id in scheduler:
+            del scheduler[task_id]
 
 
 def clone_action(wf, context):
@@ -190,6 +264,38 @@
                   destination=DRAFT,
                   history_label=_("Draft creation"))
 
+draft_to_prepublished = Transition(transition_id='draft_to_prepublished',
+                                   title=_("Pre-publish content"),
+                                   source=DRAFT,
+                                   destination=PRE_PUBLISHED,
+                                   trigger=SYSTEM,
+                                   action=prepublish_action,
+                                   history_label=_("Content pre-published"),
+                                   notify_roles={'*'},
+                                   notify_message=_("pre-published the content « {0} »"))
+
+prepublished_to_draft = Transition(transition_id='prepublished_to_draft',
+                                   title=_("Cancel publication"),
+                                   source=PRE_PUBLISHED,
+                                   destination=DRAFT,
+                                   permission=MANAGE_CONTENT_PERMISSION,
+                                   condition=can_manage_content,
+                                   action=cancel_prepublish_action,
+                                   menu_css_class='fa fa-fw fa-mail-reply',
+                                   view_name='wf-cancel-publish.html',
+                                   history_label=_("Publication canceled"),
+                                   notify_roles={WEBMASTER_ROLE, PILOT_ROLE, MANAGER_ROLE, OWNER_ROLE},
+                                   notify_message=_("cancelled the publication for content « {0} »"),
+                                   order=1)
+
+prepublished_to_published = Transition(transition_id='prepublished_to_published',
+                                       title=_("Publish content"),
+                                       source=PRE_PUBLISHED,
+                                       destination=PUBLISHED,
+                                       trigger=SYSTEM,
+                                       action=publish_action,
+                                       history_label=_("Content published"))
+
 draft_to_published = Transition(transition_id='draft_to_published',
                                 title=_("Publish"),
                                 source=DRAFT,
@@ -197,6 +303,7 @@
                                 permission=PUBLISH_CONTENT_PERMISSION,
                                 condition=can_manage_content,
                                 action=publish_action,
+                                prepared_transition=draft_to_prepublished,
                                 menu_css_class='fa fa-fw fa-thumbs-o-up',
                                 view_name='wf-publish.html',
                                 history_label=_("Content published"),
@@ -255,6 +362,9 @@
                     order=99)
 
 wf_transitions = {init,
+                  draft_to_prepublished,
+                  prepublished_to_draft,
+                  prepublished_to_published,
                   draft_to_published,
                   published_to_archived,
                   published_to_draft,
@@ -322,7 +432,8 @@
                    readonly_states=READONLY_STATES,
                    protected_states=PROTECTED_STATES,
                    manager_states=MANAGER_STATES,
-                   published_states=VISIBLE_STATES,
+                   published_states=PUBLISHED_STATES,
+                   visible_states=VISIBLE_STATES,
                    waiting_states=WAITING_STATES,
                    retired_states=RETIRED_STATES,
                    archived_states=ARCHIVED_STATES,
--- a/src/pyams_content/workflow/interfaces.py	Wed Jan 09 12:21:13 2019 +0100
+++ b/src/pyams_content/workflow/interfaces.py	Thu Jan 10 17:37:38 2019 +0100
@@ -12,16 +12,8 @@
 
 __docformat__ = 'restructuredtext'
 
-
-# import standard library
-
-# import interfaces
-from pyams_scheduler.interfaces import ITask
 from pyams_workflow.interfaces import IWorkflow
 
-# import packages
-from zope.interface import Interface
-
 
 class IContentWorkflow(IWorkflow):
     """PyAMS default content workflow marker interface"""
@@ -29,11 +21,3 @@
 
 class IBasicWorkflow(IWorkflow):
     """PyAMS basic workflow marker interface"""
-
-
-class IContentArchiverTaskInfo(Interface):
-    """Content archiver task info"""
-
-
-class IContentArchiverTask(ITask, IContentArchiverTaskInfo):
-    """Content archiver task interface"""
--- a/src/pyams_content/workflow/task.py	Wed Jan 09 12:21:13 2019 +0100
+++ b/src/pyams_content/workflow/task.py	Thu Jan 10 17:37:38 2019 +0100
@@ -12,59 +12,115 @@
 
 __docformat__ = 'restructuredtext'
 
-
-# import standard library
-from datetime import datetime
+import logging
+from datetime import datetime, timedelta
 
-# import interfaces
-from hypatia.interfaces import ICatalog
-from pyams_content.shared.common.interfaces import IBaseSharedTool
-from pyams_content.workflow.interfaces import IContentArchiverTask
-from pyams_i18n.interfaces import II18n
-from pyams_security.interfaces import INTERNAL_USER_ID
-from pyams_sequence.interfaces import ISequentialIdInfo
-from pyams_workflow.interfaces import IWorkflow, IWorkflowInfo
+from pyramid.events import subscriber
+from transaction.interfaces import ITransactionManager
+from zope.interface import implementer
 from zope.intid.interfaces import IIntIds
 
-# import packages
-from hypatia.catalog import CatalogQuery
-from hypatia.query import Eq, Lt, Any
-from pyams_catalog.query import CatalogResultSet
+from pyams_scheduler.interfaces import IDateTaskScheduling, IScheduler, ISchedulerProcess
+from pyams_scheduler.process import TaskResettingThread
 from pyams_scheduler.task import Task
-from pyams_utils.registry import get_utility, get_all_utilities_registered_for
-from pyams_utils.request import check_request
+from pyams_security.interfaces import INTERNAL_USER_ID
+from pyams_utils.registry import get_utility, query_utility
 from pyams_utils.timezone import gmtime
-from zope.interface import implementer
+from pyams_workflow.interfaces import IWorkflow, IWorkflowInfo, IWorkflowManagementTask, IWorkflowState
+from pyams_zmq.interfaces import IZMQProcessStartedEvent
 
-from pyams_content import _
+
+logger = logging.getLogger('PyAMS (content)')
 
 
-@implementer(IContentArchiverTask)
-class ContentArchiverTask(Task):
-    """"Content archiver task"""
+@implementer(IWorkflowManagementTask)
+class ContentPublishingTask(Task):
+    """Content publisher task"""
+    
+    settings_view_name = None
+    principal_id = INTERNAL_USER_ID
+
+    def __init__(self, oid, transition_id):
+        super(ContentPublishingTask, self).__init__()
+        self.oid = oid
+        self.transition_id = transition_id
+
+    def run(self, report):
+        intids = get_utility(IIntIds)
+        content = intids.queryObject(self.oid)
+        if content is None:
+            logger.debug(">>> can't find publisher task target with OID {}".format(self.oid))
+        else:
+            workflow = IWorkflow(content)
+            state = IWorkflowState(content)
+            if state.state in workflow.visible_states:
+                logger.debug(">>> content is already published!")
+            else:
+                info = IWorkflowInfo(content)
+                info.fire_transition(self.transition_id,
+                                     check_security=False,
+                                     principal=self.principal_id)
+                info.fire_automatic()
+        # remove task after execution!
+        if self.__parent__ is not None:
+            del self.__parent__[self.__name__]
+
+
+@implementer(IWorkflowManagementTask)
+class ContentArchivingTask(Task):
+    """Content archiving task"""
 
     settings_view_name = None
+    principal_id = INTERNAL_USER_ID
+
+    def __init__(self, oid):
+        super(ContentArchivingTask, self).__init__()
+        self.oid = oid
 
     def run(self, report):
-        request = check_request()
-        translate = request.localizer.translate
         intids = get_utility(IIntIds)
-        catalog = get_utility(ICatalog)
-        now = gmtime(datetime.utcnow())
-        has_retired = False
-        for tool in get_all_utilities_registered_for(IBaseSharedTool):
-            workflow = IWorkflow(tool)
-            params = Eq(catalog['parents'], intids.register(tool)) & \
-                Any(catalog['workflow_state'], workflow.published_states) & \
-                Lt(catalog['expiration_date'], now)
-            for content in CatalogResultSet(CatalogQuery(catalog).query(params)):
-                if not has_retired:
-                    report.write(translate(_("Automatic contents withdrawal:\n")))
-                    has_retired = True
+        content = intids.queryObject(self.oid)
+        if content is None:
+            logger.debug(">>> can't find archiving task target with OID {}".format(self.oid))
+        else:
+            workflow = IWorkflow(content)
+            state = IWorkflowState(content)
+            if state.state not in workflow.visible_states:
+                logger.debug(">>> content is not currently published!")
+            else:
                 info = IWorkflowInfo(content)
                 info.fire_transition_toward(workflow.auto_retired_state,
                                             check_security=False,
-                                            principal=INTERNAL_USER_ID)
+                                            principal=self.principal_id)
                 info.fire_automatic()
-                report.write("- {0} ({1})\n".format(II18n(content).query_attribute('title', request=request),
-                                                    ISequentialIdInfo(content).hex_oid))
+        # remove task after execution!
+        if self.__parent__ is not None:
+            del self.__parent__[self.__name__]
+
+
+@subscriber(IZMQProcessStartedEvent, context_selector=ISchedulerProcess)
+def handle_scheduler_start(event):
+    """Check for scheduler tasks
+
+    Workflow management tasks are typically automatically deleted after their execution.
+    If tasks with passed execution date are still present in the scheduler, this is generally
+    because scheduler was stopped at task execution time; so tasks which where not run are
+    re-scheduled at process startup in a very near future...
+    """
+    scheduler = query_utility(IScheduler)
+    logger.debug("Checking dangling scheduler tasks on {!r}".format(scheduler))
+    if scheduler is not None:
+        for task in scheduler.values():
+            if not IWorkflowManagementTask.providedBy(task):
+                continue
+            schedule_info = IDateTaskScheduling(task, None)
+            if schedule_info is None:  # no date scheduling
+                continue
+            now = gmtime(datetime.utcnow())
+            if schedule_info.active and (schedule_info.start_date < now):
+                logger.debug(" - resetting task « {} »".format(task.name))
+                schedule_info.start_date = now + timedelta(seconds=10)
+                # commit task update for reset thread!!
+                ITransactionManager(task).commit()
+                # start task resetting thread
+                TaskResettingThread(event.object, task).start()
--- a/src/pyams_content/workflow/zmi/__init__.py	Wed Jan 09 12:21:13 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +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
-
-# import packages
--- a/src/pyams_content/workflow/zmi/task.py	Wed Jan 09 12:21:13 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,58 +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_skin.interfaces.viewlet import IToolbarAddingMenu
-from pyams_skin.layer import IPyAMSLayer
-from pyams_utils.interfaces import MANAGE_SYSTEM_PERMISSION
-from zope.component.interfaces import ISite
-
-# import packages
-from pyams_content.workflow.task import ContentArchiverTask
-from pyams_form.form import AJAXAddForm, ajax_config
-from pyams_pagelet.pagelet import pagelet_config
-from pyams_scheduler.zmi.scheduler import SchedulerTasksTable
-from pyams_scheduler.zmi.task import TaskBaseAddForm
-from pyams_skin.viewlet.toolbar import ToolbarMenuItem
-from pyams_viewlet.viewlet import viewlet_config
-
-from pyams_content import _
-
-
-@viewlet_config(name='add-content-archiver-task.menu', context=ISite, layer=IPyAMSLayer,
-                view=SchedulerTasksTable, manager=IToolbarAddingMenu,
-                permission=MANAGE_SYSTEM_PERMISSION, weight=100)
-class ContentArchiverTaskAddMenu(ToolbarMenuItem):
-    """Content archiver task add menu"""
-
-    label = _("Add content archiver task...")
-    label_css_class = 'fa fa-fw fa-archive'
-    url = 'add-content-archiver-task.html'
-    modal_target = True
-
-
-@pagelet_config(name='add-content-archiver-task.html', context=ISite, layer=IPyAMSLayer,
-                permission=MANAGE_SYSTEM_PERMISSION)
-@ajax_config(name='add-content-archiver-task.json', context=ISite, layer=IPyAMSLayer,
-             permission=MANAGE_SYSTEM_PERMISSION, base=AJAXAddForm)
-class ContentArchiverTaskAddForm(TaskBaseAddForm):
-    """Content archiver task add form"""
-
-    legend = _("Add automatic content archiver")
-    icon_css_class = 'fa fa-fw fa-archive'
-
-    task_factory = ContentArchiverTask