Tuesday, October 26, 2010

All-in-1 Workflow

 

In this article I’m going to describe an approach that we’ve taken creating a solution for a universal workflow requirement. The idea was to create one workflow for all content items as they all would follow the same workflow process.

Here is a use case that came from one of our wonderful customers:

  • All content items should have the same workflow process.
  • Different security roles may have or not have access to a workflow state at different parts of a content tree.
  • Workbox should respect aforementioned security configuration.

To understand what access rights provide editing permission to the user let’s take a look at the way Sitecore resolves access level to a content item.

  • Check if a user has Read (item:read) access to an item. If so, the user will be able to see the item in a Content Editor.image
  • Check if the user has Write (item:write) access to the item. If the item is in workflow, check whether the user has Write access to a workflow state the item is in (workflowState:write). If either of those access rights is not granted, reclaim modification access.
    image

As you can see both Write and Workflow State Write access rights are required for a user to edit the item.

This is the approach we came up with to address all the requirements. We can meet all requirements by extending standard Workflow class and using it to run workflow process.

First. Define additional access right that along with Workflow state Write one would determine access to items in workflow at different parts of the content tree. In this approach we decided to use “workflowState:write” access right which is available only to workflow related items by default. In this case the only thing we need to do is to make it available for all of the items. And it could be easily configured in web.config file:

Code Snippet
  1. <rules>
  2.   <add prefix="workflowState:write" typeName="Sitecore.Data.Items.Item"/>
  3. </rules>

Now you can set this right for any item in Security Editor. Just don’t forget to add an appropriate column to see it there.

image

Second. Extend default Sitecore.Workflows.Simple.Workflow class to take into account new security configuration.

Code Snippet
  1. namespace TwinPeaks.Workflows
  2. {
  3.     public class Workflow : Sitecore.Workflows.Simple.Workflow, IWorkflow
  4.     {
  5.         private const string CheckRequiredFieldName = "Check required";
  6.  
  7.         public Workflow(string workflowId, WorkflowProvider owner)
  8.             : base(workflowId, owner)
  9.         {
  10.             Owner = owner;
  11.         }
  12.  
  13.         /// <summary>
  14.         /// Returns workflow state commands.
  15.         /// </summary>
  16.         /// <param name="item">Content item.</param>
  17.         /// <returns></returns>
  18.         public override WorkflowCommand[] GetCommands(Item item)
  19.         {
  20.             Assert.ArgumentNotNull(item, "item");
  21.             string stateID = this.GetStateID(item);
  22.             if (stateID.Length > 0)
  23.             {
  24.                 return GetCommands(stateID, item);
  25.             }
  26.             return new WorkflowCommand[0];
  27.         }
  28.  
  29.         /// <summary>
  30.         /// Returns workflow state commands.
  31.         /// </summary>
  32.         /// <param name="stateId">Workflow state ID</param>
  33.         /// <param name="item">Content item</param>
  34.         /// <returns></returns>
  35.         public WorkflowCommand[] GetCommands(string stateId, Item item)
  36.         {
  37.             Assert.ArgumentNotNullOrEmpty(stateId, "stateID");
  38.             Item stateItem = GetStateItem(stateId);
  39.             WorkflowState workflowState = GetState(stateId);
  40.             if (stateItem == null || workflowState == null)
  41.             {
  42.                 return new WorkflowCommand[0];
  43.             }
  44.             Item[] itemArray = stateItem.Children.ToArray();
  45.             ArrayList list = new ArrayList();
  46.             foreach (Item entity in itemArray)
  47.             {
  48.                 if (entity != null)
  49.                 {
  50.                     Template template = entity.Database.Engines.TemplateEngine.GetTemplate(entity.TemplateID);
  51.                     if (workflowState.CheckRequired && !string.IsNullOrEmpty(AccessRight.WorkflowStateWrite.Name))
  52.                     {
  53.                         if (((template != null) && template.DescendsFromOrEquals(TemplateIDs.WorkflowCommand)) &&
  54.                         AuthorizationManager.IsAllowed(entity, AccessRight.WorkflowCommandExecute, Context.User) &&
  55.                         AuthorizationManager.IsAllowed(item, AccessRight.FromName(AccessRight.WorkflowStateWrite.Name), Context.User))
  56.                         {
  57.                             list.Add(new WorkflowCommand(entity.ID.ToString(), entity.DisplayName,
  58.                                                          entity.Appearance.Icon, false,
  59.                                                          entity["suppress comment"] == "1"));
  60.                         }
  61.                     }
  62.                     else if (((template != null) && template.DescendsFromOrEquals(TemplateIDs.WorkflowCommand)) &&
  63.                         AuthorizationManager.IsAllowed(entity, AccessRight.WorkflowCommandExecute, Context.User))
  64.                     {
  65.                         list.Add(new WorkflowCommand(entity.ID.ToString(), entity.DisplayName, entity.Appearance.Icon, false, entity["suppress comment"] == "1"));
  66.                     }
  67.                 }
  68.             }
  69.             return (WorkflowCommand[])list.ToArray(typeof(WorkflowCommand));
  70.         }
  71.  
  72.         /// <summary>
  73.         /// Returns workflow state item
  74.         /// </summary>
  75.         /// <param name="stateId">Workflow state ID</param>
  76.         /// <returns></returns>
  77.         protected Item GetStateItem(string stateId)
  78.         {
  79.             ID iD = MainUtil.GetID(stateId, null);
  80.             if (iD == (ID)null)
  81.             {
  82.                 return null;
  83.             }
  84.             return ItemManager.GetItem(stateId, Language.Current, Version.Latest, Owner.Database, SecurityCheck.Disable);
  85.         }
  86.  
  87.         /// <summary>
  88.         /// Returns workflow state ID
  89.         /// </summary>
  90.         /// <param name="item">Content item</param>
  91.         /// <returns></returns>
  92.         protected string GetStateID(Item item)
  93.         {
  94.             Assert.ArgumentNotNull(item, "item");
  95.             WorkflowInfo workflowInfo = item.Database.DataManager.GetWorkflowInfo(item);
  96.             if (workflowInfo != null)
  97.             {
  98.                 return workflowInfo.StateID;
  99.             }
  100.             return string.Empty;
  101.         }
  102.  
  103.         /// <summary>
  104.         /// Need to override to respect new right in Workbox application
  105.         /// </summary>
  106.         public override DataUri[] GetItems(string stateId)
  107.         {
  108.             if (CheckStateAdvancedSecurity(stateId))
  109.             {
  110.                 Assert.ArgumentNotNullOrEmpty(stateId, "stateID");
  111.                 Assert.IsTrue(ID.IsID(stateId), "Invalid state ID: " + stateId);
  112.                 DataUri[] itemsInWorkflowState =
  113.                     Owner.Database.DataManager.GetItemsInWorkflowState(new WorkflowInfo(WorkflowID, stateId));
  114.                 DataUri[] filteredItems = ApplyAdvancedSecurity(itemsInWorkflowState, stateId);
  115.                 if (filteredItems != null)
  116.                 {
  117.                     return filteredItems;
  118.                 }
  119.                 return new DataUri[0];
  120.             }
  121.             return base.GetItems(stateId);
  122.         }
  123.  
  124.         /// <summary>
  125.         /// Indicates if advanced security should be checked for a workflow state.
  126.         /// </summary>
  127.         /// <param name="stateId">Workflow satate ID</param>
  128.         /// <returns></returns>
  129.         protected bool CheckStateAdvancedSecurity(string stateId)
  130.         {
  131.             WorkflowState workflowState = GetState(stateId);
  132.             if (workflowState != null && workflowState.CheckRequired && !string.IsNullOrEmpty(AccessRight.WorkflowStateWrite.Name))
  133.             {
  134.                 return true;
  135.             }
  136.  
  137.             return false;
  138.         }
  139.  
  140.         /// <summary>
  141.         /// Filters out items that a user should not have access to.
  142.         /// </summary>
  143.         /// <param name="items">DataUri array of content items.</param>
  144.         /// <param name="stateId">Workflow state ID.</param>
  145.         /// <returns></returns>
  146.         protected DataUri[] ApplyAdvancedSecurity(DataUri[] items, string stateId)
  147.         {
  148.             if (items == null || items.Length == 0)
  149.             {
  150.                 return new DataUri[0];
  151.             }
  152.             WorkflowState workflowState = GetState(stateId);
  153.             if (workflowState == null)
  154.             {
  155.                 return new DataUri[0];
  156.             }
  157.             var filteredItems =
  158.                 items.Where(
  159.                     item => Owner.Database.GetItem(item) != null &&
  160.                             AuthorizationManager.IsAllowed(Owner.Database.GetItem(item),
  161.                                                            AccessRight.FromName(AccessRight.WorkflowStateWrite.Name),
  162.                                                            Context.User));
  163.             if (!filteredItems.GetEnumerator().MoveNext())
  164.             {
  165.                 return new DataUri[0];
  166.             }
  167.             return filteredItems.ToArray();
  168.         }
  169.  
  170.         /// <summary>
  171.         /// Returns an extended WorkflowState object.
  172.         /// </summary>
  173.         /// <param name="stateId">Workflow state ID.</param>
  174.         /// <returns></returns>
  175.         new protected WorkflowState GetState(string stateId)
  176.         {
  177.             Assert.ArgumentNotNullOrEmpty(stateId, "stateId");
  178.             Item stateItem = GetStateItem(stateId);
  179.             if (stateItem != null)
  180.             {
  181.                 return new WorkflowState(stateId, stateItem.DisplayName, stateItem.Appearance.Icon, stateItem[WorkflowFieldIDs.FinalState] == "1", stateItem[CheckRequiredFieldName] == "1");
  182.             }
  183.             return null;
  184.         }
  185.  
  186.         /// <summary>
  187.         /// Returns access result of whether the user has write access to the item.
  188.         /// </summary>
  189.         /// <param name="item">Content item.</param>
  190.         /// <param name="account">User account</param>
  191.         /// <param name="accessRight">Access right</param>
  192.         /// <returns></returns>
  193.         new public AccessResult GetAccess(Item item, Account account, AccessRight accessRight)
  194.         {
  195.             Assert.ArgumentNotNull(item, "item");
  196.             Assert.ArgumentNotNull(account, "account");
  197.             Assert.ArgumentNotNull(accessRight, "operation");
  198.             Item stateItem = GetStateItem(item);
  199.             if (stateItem == null)
  200.             {
  201.                 return new AccessResult(AccessPermission.Allow, new AccessExplanation(item, account, AccessRight.ItemDelete, "The workflow state definition item not found.", new object[0]));
  202.             }
  203.             if (accessRight == AccessRight.ItemWrite)
  204.             {
  205.                 return GetWriteAccessInformation(item, account, stateItem);
  206.             }
  207.             return base.GetAccess(item, account, accessRight);
  208.         }
  209.  
  210.         /// <summary>
  211.         /// Resolves whether the user has write access to the item.
  212.         /// </summary>
  213.         /// <param name="item">Content item.</param>
  214.         /// <param name="account">User account.</param>
  215.         /// <param name="stateItem">Workflow state item.</param>
  216.         /// <returns></returns>
  217.         protected AccessResult GetWriteAccessInformation(Item item, Account account, Item stateItem)
  218.         {
  219.             WorkflowState workflowState = GetState(stateItem.ID.ToString());
  220.             if (workflowState != null && workflowState.CheckRequired)
  221.             {
  222.                 if (AuthorizationManager.IsAllowed(stateItem, AccessRight.WorkflowStateWrite, account) && AuthorizationManager.IsAllowed(item, AccessRight.WorkflowStateWrite, account))
  223.                 {
  224.                     return new AccessResult(AccessPermission.Allow, new AccessExplanation(item, account, AccessRight.ItemWrite, string.Format("The workflow state definition item allows writing (through the '{0}' access right).", AccessRight.WorkflowStateWrite.Name), new object[0]));
  225.                 }
  226.             }
  227.             else if (AuthorizationManager.IsAllowed(stateItem, AccessRight.WorkflowStateWrite, account))
  228.             {
  229.                 return new AccessResult(AccessPermission.Allow, new AccessExplanation(item, account, AccessRight.ItemWrite, string.Format("The workflow state definition item allows writing (through the '{0}' access right).", AccessRight.WorkflowStateWrite.Name), new object[0]));
  230.             }
  231.             return new AccessResult(AccessPermission.Deny, new AccessExplanation(item, account, AccessRight.ItemWrite, string.Format("The workflow state definition item does not allow writing. To allow writing, grant the '{0}' access right to the workflow state definition item.", AccessRight.WorkflowStateWrite.Name), new object[0]));
  232.         }
  233.  
  234.         /// <summary>
  235.         /// Returns workflow state item the content item is in.
  236.         /// </summary>
  237.         /// <param name="item">Content item.</param>
  238.         /// <returns></returns>
  239.         protected Item GetStateItem(Item item)
  240.         {
  241.             Assert.ArgumentNotNull(item, "item");
  242.             WorkflowInfo info = item.Database.DataManager.GetWorkflowInfo(item);
  243.             if (info != null)
  244.             {
  245.                 return item.Database.SelectSingleItem(info.StateID);
  246.             }
  247.             return null;
  248.         }
  249.  
  250.         #region Properties
  251.  
  252.         protected WorkflowProvider Owner { get; set; }
  253.  
  254.         #endregion Properties
  255.     }
  256. }

To provide an ability to choose whether access to a workflow state should be combined with access to a content item, I extended System/Workflow/State template with a checkbox field that indicates whether a custom logic should be triggered. Here how it looks now:

image

I extended WorkflowState class with an appropriate property for the new field.

Code Snippet
  1. namespace TwinPeaks.Workflows
  2. {
  3.     public class WorkflowState : Sitecore.Workflows.WorkflowState
  4.     {
  5.         public WorkflowState(string stateId, string displayName, string icon, bool finalState, bool checkRequired) : base(stateId, displayName, icon, finalState)
  6.         {
  7.             CheckRequired = checkRequired;
  8.         }
  9.  
  10.         /// <summary>
  11.         /// Indicates if workflowState:write access right should be considered while resolving access to the item.
  12.         /// </summary>
  13.         public bool CheckRequired { get; private set; }
  14.     }
  15. }

Now in order to make Sitecore use our new Workflow class we need to override WorkflowProvider to return our extended Workflow instance.

Code Snippet
  1. using Sitecore;
  2. using Sitecore.Data;
  3. using Sitecore.Data.Items;
  4. using Sitecore.Diagnostics;
  5. using Sitecore.Workflows;
  6.  
  7. namespace TwinPeaks.Workflows
  8. {
  9.     /// <summary>
  10.     /// This class overrides required methods to return an object of extended Workflow class.
  11.     /// </summary>
  12.     public class WorkflowProvider : Sitecore.Workflows.Simple.WorkflowProvider
  13.     {
  14.         public WorkflowProvider(string databaseName, HistoryStore historyStore) : base(databaseName, historyStore)
  15.         {
  16.         }
  17.  
  18.         public override IWorkflow GetWorkflow(Item item)
  19.         {
  20.             Assert.ArgumentNotNull(item, "item");
  21.             string workflowID = GetWorkflowID(item);
  22.             if (workflowID.Length > 0)
  23.             {
  24.                 return new Workflow(workflowID, this);
  25.             }
  26.             return null;
  27.         }
  28.  
  29.         public override IWorkflow GetWorkflow(string workflowID)
  30.         {
  31.             Assert.ArgumentNotNullOrEmpty(workflowID, "workflowID");
  32.             Error.Assert(ID.IsID(workflowID), "The parameter 'workflowID' must be parseable to an ID");
  33.             if (this.Database.Items[ID.Parse(workflowID)] != null)
  34.             {
  35.                 return new Workflow(workflowID, this);
  36.             }
  37.             return null;
  38.         }
  39.  
  40.         private static string GetWorkflowID(Item item)
  41.         {
  42.             Assert.ArgumentNotNull(item, "item");
  43.             WorkflowInfo workflowInfo = item.Database.DataManager.GetWorkflowInfo(item);
  44.             if (workflowInfo != null)
  45.             {
  46.                 return workflowInfo.WorkflowID;
  47.             }
  48.             return string.Empty;
  49.         }
  50.  
  51.         public override IWorkflow[] GetWorkflows()
  52.         {
  53.             Item item = this.Database.Items[ItemIDs.WorkflowRoot];
  54.             if (item == null)
  55.             {
  56.                 return new IWorkflow[0];
  57.             }
  58.             Item[] itemArray = item.Children.ToArray();
  59.             IWorkflow[] workflowArray = new IWorkflow[itemArray.Length];
  60.             for (int i = 0; i < itemArray.Length; i++)
  61.             {
  62.                 workflowArray[i] = new Workflow(itemArray[i].ID.ToString(), this);
  63.             }
  64.             return workflowArray;
  65.         }
  66.     }
  67. }

Third. Configure Sitecore solution to work with this customization. Below is a complete example of UniversalWorkflow.config file that could be placed into /App_Config/Include folder to enable this customization:

Code Snippet
  1. <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  2.   <sitecore>
  3.     <databases>
  4.       <database id="master">
  5.         <workflowProvider>
  6.           <patch:attribute name="type">TwinPeaks.Workflows.WorkflowProvider, TwinPeaks.Workflows</patch:attribute>
  7.         </workflowProvider>
  8.       </database>
  9.     </databases>
  10.     <accessRights defaultProvider="config">
  11.       <rules>
  12.         <add prefix="workflowState:write" typeName="Sitecore.Data.Items.Item"/>
  13.       </rules>
  14.     </accessRights>
  15.  
  16.   </sitecore>
  17. </configuration>

Why is this solution is worth to blog about? Because it allows us to address all the requirements by customizing only one thing – Workflow class. Both Workbox and Content Editor will respect security configuration if “check required” field is selected on a workflow state item.

Feel free to share your thoughts on this approach as well as suggest improvements or even better solution.
Hope you find it helpful.

No comments: