# HG changeset patch # User Thierry Florac # Date 1547138258 -3600 # Node ID 482e0e1f192f0eb6496a220dc4510ecd7694451c # Parent 0e72ad321c13a2d80b95f96edaeb0f063cdc9172 Updated workflow management to handle content's "pre-publication" diff -r 0e72ad321c13 -r 482e0e1f192f src/pyams_content/shared/common/zmi/templates/wf-cancel-publish-message.pt --- /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 @@ +

+ After cancelling the publication, the content will return to proposed publication state. +

diff -r 0e72ad321c13 -r 482e0e1f192f src/pyams_content/shared/common/zmi/workflow.py --- 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 # diff -r 0e72ad321c13 -r 482e0e1f192f src/pyams_content/workflow/__init__.py --- 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, diff -r 0e72ad321c13 -r 482e0e1f192f src/pyams_content/workflow/basic.py --- 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, diff -r 0e72ad321c13 -r 482e0e1f192f src/pyams_content/workflow/interfaces.py --- 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""" diff -r 0e72ad321c13 -r 482e0e1f192f src/pyams_content/workflow/task.py --- 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() diff -r 0e72ad321c13 -r 482e0e1f192f src/pyams_content/workflow/zmi/__init__.py --- 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 -# 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 diff -r 0e72ad321c13 -r 482e0e1f192f src/pyams_content/workflow/zmi/task.py --- 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 -# 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