Source code for collective.multiworkflow.api

"""Workflow-aware equivalents of the ``plone.api`` workflow helpers.

Every signature is a **pure superset** of what ``plone.api.content`` offers
today: called without ``workflow_id`` these behave exactly as their upstream
counterparts, reading and driving ``review_state``. Passing ``workflow_id``
addresses one specific workflow in the object's chain.
"""

from plone import api as plone_api
from plone.api.exc import InvalidParameterError
from plone.dexterity.content import DexterityContent
from Products.CMFCore.utils import getToolByName
from Products.CMFPlone.WorkflowTool import WorkflowTool
from Products.DCWorkflow.DCWorkflow import DCWorkflowDefinition
from typing import Any


_marker = object()


def _tool(obj: Any) -> WorkflowTool:
    """Return the workflow tool for an object.

    :param obj: any content object.
    :returns: the ``portal_workflow`` tool.
    """
    return getToolByName(obj, "portal_workflow")


def _workflow(wftool: WorkflowTool, workflow_id: str) -> DCWorkflowDefinition:
    """Look a workflow up, failing loudly on an unknown id.

    :param wftool: the ``portal_workflow`` tool.
    :param workflow_id: id of the workflow to look up.
    :returns: the workflow definition.
    :raises InvalidParameterError: if no such workflow is registered.
    """
    workflow = wftool.getWorkflowById(workflow_id)
    if workflow is None:
        raise InvalidParameterError(f"Unknown workflow '{workflow_id}'.")
    return workflow


[docs] def get_state( obj: DexterityContent, default: Any = _marker, workflow_id: str | None = None ) -> str: """Get the object's current state, optionally in a specific workflow. With no ``workflow_id`` this is ``plone.api.content.get_state``: it returns ``review_state``. ``workflow_id`` selects one workflow from the chain and returns the value of *that* workflow's state variable. :param obj: object to read the state of. :param default: returned if the object has no workflow at all. :param workflow_id: id of the workflow to read; ``None`` means the effective ``review_state``. :returns: the current state id. :raises InvalidParameterError: if ``workflow_id`` is not registered. :raises WorkflowException: if the state cannot be determined. """ wftool = _tool(obj) if default is not _marker and not wftool.getWorkflowsFor(obj): return default if workflow_id is None: return wftool.getInfoFor(ob=obj, name="review_state") workflow = _workflow(wftool, workflow_id) return wftool.getInfoFor(obj, workflow.state_var, wf_id=workflow_id)
[docs] def get_states(obj: DexterityContent) -> dict[str, str]: """Get the object's state in every workflow of its chain. :param obj: object to read the states of. :returns: mapping of workflow id to the current state id, in chain order. """ wftool = _tool(obj) return { workflow.getId(): wftool.getInfoFor( obj, workflow.state_var, wf_id=workflow.getId() ) for workflow in wftool.getWorkflowsFor(obj) or [] }
[docs] def transition( obj: DexterityContent, transition: str | None = None, to_state: str | None = None, workflow_id: str | None = None, **kwargs: Any, ) -> None: """Perform a workflow transition, optionally on a specific workflow. With no ``workflow_id`` the transition is routed exactly as ``plone.api.content.transition`` routes it — the first workflow in the chain that supports the id wins. Pass ``workflow_id`` to address a workflow whose transition id is shadowed by an earlier one in the chain. :param obj: object to transition. :param transition: id of the transition to perform. :param to_state: target state, as an alternative to ``transition``. Only meaningful for the publication workflow, so it may not be combined with ``workflow_id``. :param workflow_id: id of the workflow owning the transition. :param kwargs: passed through to the workflow, e.g. ``comment``. :raises InvalidParameterError: if the transition or workflow is invalid, or if ``to_state`` and ``workflow_id`` are combined. """ if workflow_id is None: plone_api.content.transition( obj=obj, transition=transition, to_state=to_state, **kwargs ) return if to_state is not None: raise InvalidParameterError( "'to_state' cannot be combined with 'workflow_id'; name the " "transition explicitly instead." ) wftool = _tool(obj) workflow = _workflow(wftool, workflow_id) # Mirror what the tool does for the un-targeted path: it selects a workflow # with ``isActionSupported`` and raises WorkflowException — which # ``plone.api`` turns into InvalidParameterError — when none matches. # Calling ``doActionFor`` on a workflow that does not support the action # would instead raise Unauthorized, which is not the upstream contract. if not workflow.isActionSupported(obj, transition, **kwargs): available = sorted(transitions(obj).get(workflow_id, [])) raise InvalidParameterError( "Invalid transition '{}' for workflow '{}'.\n" "Valid transitions are:\n{}".format( transition, workflow_id, "\n".join(available) ) ) wftool.doActionFor(obj, transition, wf_id=workflow_id, **kwargs)
[docs] def transitions(obj: DexterityContent) -> dict[str, list[str]]: """Get the transitions available to the current user, by workflow. :param obj: object to inspect. :returns: mapping of workflow id to the ids of its available transitions. Workflows with nothing available are omitted. """ wftool = _tool(obj) owners = owning_workflow(obj) available: dict[str, list[str]] = {} for action in wftool.listActionInfos(object=obj): if action["category"] != "workflow": continue workflow_id = owners.get(action["id"]) if workflow_id is None: continue available.setdefault(workflow_id, []).append(action["id"]) return available
[docs] def conflicting_permissions(obj: DexterityContent) -> dict[str, list[str]]: """Find permissions claimed by more than one workflow in the chain. Concurrent workflows compose safely as long as the permission sets they manage are **disjoint**: ``DCWorkflowDefinition.updateRoleMappingsFor`` only rewrites the permissions listed in that workflow's own ``permissions``, and executing a transition re-applies the mappings of the transitioning workflow alone. A permission claimed by two workflows is therefore left as whichever of them transitioned last wrote it, and stays that way until the other one transitions or ``portal_workflow.updateRoleMappings()`` runs. Use this to audit a site — from an upgrade step, a test, or a debugging session — before trusting a chain's role mappings. :param obj: object whose chain is inspected. :returns: mapping of permission to the ids of the workflows managing it, in chain order. Permissions with a single claimant are omitted, so an empty mapping means the chain is conflict-free. """ claimants: dict[str, list[str]] = {} for workflow in _tool(obj).getWorkflowsFor(obj) or []: # Not every workflow implementation declares managed permissions. for permission in getattr(workflow, "permissions", ()) or (): claimants.setdefault(permission, []).append(workflow.getId()) return { permission: workflows for permission, workflows in claimants.items() if len(workflows) > 1 }
[docs] def owning_workflow(obj: DexterityContent) -> dict[str, str]: """Map every transition id in the object's chain to its owning workflow. A transition id defined by more than one workflow is attributed to the first one in chain order, which is exactly how ``doActionFor`` resolves the collision. :param obj: object whose chain is inspected. :returns: mapping of transition id to workflow id. """ owners: dict[str, str] = {} for workflow in _tool(obj).getWorkflowsFor(obj) or []: definitions = getattr(workflow, "transitions", None) if definitions is None: # Not every workflow implementation is DCWorkflow-based. continue for transition_id in definitions.objectIds(): owners.setdefault(transition_id, workflow.getId()) return owners