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