Thursday, September 02, 2010

Publish to pre-production web database. Part 2.


A while back I shared a technique of publishing to a pre-production database via a custom ExtendedPublishProvider. As much as I like the clarity of this approach, as Kamsar mentioned in the comments, “…it has a downside: if you run a smart publish site to your staging database it will "unstage" any items that workflow staged into them with the current published version, ignoring the workflow state.”

Valid point.

After some internal brainstorming on the subject with our US based tech team, here is another approach to the problem that eliminates the drawback mentioned above.

What you can actually do is plug in a custom WorkflowProvider which will refer to a custom implementation of Sitecore.Workflows.Simple.Workflow class. This class has a method IsApproved(Item item) that is called from all publishing operations. We are going to override that and check if the item we are checking approval for is currently in a “semi-final” workflow state and if the database we publish to matches the designated “pre-production” database. Pretty simple, huh?

Here is the code.

1. First you need a custom version of the WorkflowProvider implementation:

using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Workflows;
namespace Sitecore.Starterkit.Workflow
{
   public class WorkflowProvider : Sitecore.Workflows.Simple.WorkflowProvider
   {
      public WorkflowProvider(string databaseName, HistoryStore historyStore)
         : base(databaseName, historyStore)
      {
      }
      public override IWorkflow GetWorkflow(Item item)
      {
         Assert.ArgumentNotNull(item, "item");
         string workflowID = GetWorkflowID(item);
         if (workflowID.Length > 0)
         {
            // customization
            return new AdvancedWorkflow(workflowID, this);
            // customization
         }
         return null;
      }
      private static string GetWorkflowID(Item item)
      {
         Assert.ArgumentNotNull(item, "item");
         WorkflowInfo workflowInfo = item.Database.DataManager.GetWorkflowInfo(item);
         if (workflowInfo != null)
         {
            return workflowInfo.WorkflowID;
         }
         return string.Empty;
      }
      public override IWorkflow GetWorkflow(string workflowID)
      {
         Assert.ArgumentNotNullOrEmpty(workflowID, "workflowID");
         Error.Assert(ID.IsID(workflowID), "The parameter 'workflowID' must be parseable to an ID");
         if (Database.Items[ID.Parse(workflowID)] != null)
         {
            // customization
            return new AdvancedWorkflow(workflowID, this);
            // customization
         }
         return null;
      }
      public override IWorkflow[] GetWorkflows()
      {
         Item item = this.Database.Items[ItemIDs.WorkflowRoot];
         if (item == null)
         {
            return new IWorkflow[0];
         }
         Item[] itemArray = item.Children.ToArray();
         IWorkflow[] workflowArray = new IWorkflow[itemArray.Length];
         for (int i = 0; i < itemArray.Length; i++)
         {
            // customization
            var wfId = itemArray[i].ID.ToString();
            workflowArray[i] = new AdvancedWorkflow(wfId, this);
            // customization
         }
         return workflowArray;
      }
   }
}

2. Then you will need to attach it to the “master” database:

<!-- master -->
<database id="master" singleInstance="true" type="Sitecore.Data.Database, Sitecore.Kernel">
   <param desc="name">$(id)</param>
   ...
   <workflowProvider hint="defer" type="Sitecore.Starterkit.Workflow.WorkflowProvider, Sitecore.Starterkit">
      <param desc="database">$(id)</param>
      <param desc="history store" ref="workflowHistoryStores/main" param1="$(id)" />
    </workflowProvider>

 

3. Since the custom WorkflowProvider is referring to AdvancedWorkflow implementation, you need the code for that:

using System;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Data.Managers;
using Sitecore.Diagnostics;
using Sitecore.Globalization;
using Sitecore.SecurityModel;
using Sitecore.Workflows;
using Version = Sitecore.Data.Version;
namespace Sitecore.Starterkit.Workflow
{
   public class AdvancedWorkflow : Sitecore.Workflows.Simple.Workflow
   {
      private readonly WorkflowProvider _owner;
      public AdvancedWorkflow(string workflowID, WorkflowProvider owner)
                             : base(workflowID, owner)
      {
         _owner = owner;
      }
      private Database Database
      {
         get
         {
            return _owner.Database;
         }
      }
      public override bool IsApproved(Item item)
      {
         var result = base.IsApproved(item);
         if (!result && 
            Context.Site.Name.Equals("publisher",
                                     StringComparison.InvariantCultureIgnoreCase))
         {
            var stateItem = GetStateItem(item);
            if (stateItem != null &&
                MatchTargetDatabase(stateItem) &&
                IgnoreWorkflow(stateItem))
            {
               result = true;
            }
         }
         return result;
      }
      protected virtual bool MatchTargetDatabase(Item stateItem)
      {
         if (Context.Job != null && !String.IsNullOrEmpty(Context.Job.Name))
         {
            var target = TargetDatabase(stateItem);
            return Context.Job.Name.Equals(
                     String.Format("Publish to '{0}'", target), StringComparison.InvariantCultureIgnoreCase);
         }
         return false;
      }
      protected virtual string TargetDatabase(Item stateItem)
      {
         var publishTargetId = stateItem["Semi-Final Target Database"];
         var publishTargetItem = PublishActionHelper.GetItemById(publishTargetId);
         if(publishTargetItem != null)
         {
            return PublishActionHelper.GetFieldValue(publishTargetItem, "Target database");
         }
         return String.Empty;
      }
      protected virtual bool IgnoreWorkflow(Item stateItem)
      {
         return stateItem["Semi-Final"] == "1";
      }
      private Item GetStateItem(Item item)
      {
         string stateID = GetStateID(item);
         if (stateID.Length > 0)
         {
            return GetStateItem(stateID);
         }
         return null;
      }
      private Item GetStateItem(ID stateId)
      {
         return ItemManager.GetItem(stateId, Language.Current, Version.Latest, Database, SecurityCheck.Disable);
      }
      private Item GetStateItem(string stateId)
      {
         ID id = MainUtil.GetID(stateId, null);
         return id == (ID)null ? null : this.GetStateItem(id);
      }
      private string GetStateID(Item item)
      {
         Assert.ArgumentNotNull(item, "item");
         WorkflowInfo workflowInfo = item.Database.DataManager.GetWorkflowInfo(item);
         if (workflowInfo != null)
         {
            return workflowInfo.StateID;
         }
         return string.Empty;
      }
   }
}

All the magic is happening within “IsApproved” method that we override. We check if both “MatchTargetDatabase()” and “IgnoreWorkflow()” methods return true. The majority of other methods in this class are here because the derived class could not inherit those.

4. I reference PublishActionHelper class here too, it provides some utility methods for me:

 public class PublishActionHelper
   {
      public static Database Db
      {
         get { return Context.ContentDatabase ?? Context.Database ?? Factory.GetDatabase("master"); }
      }
      public static string GetFieldValue(Item item, string fieldName)
      {
         return item[fieldName] ?? String.Empty;
      }
      public static Item GetItemById(string id)
      {
         if (ID.IsID(id))
         {
            return Db.GetItem(ID.Parse(id));
         }
         return null;
      }
      public static string GetTargetDatabaseName(string targetId)
      {
         var publishingTarget = Db.SelectSingleItem(targetId);
         return publishingTarget["Target database"] ?? String.Empty;
      }
      public static IEnumerable<Item> GetItemsFromMultilist(Item carrier, string fieldName)
      {
         var multilistField = carrier.Fields[fieldName];
         if(FieldTypeManager.GetField(multilistField) is MultilistField)
         {
            return ((MultilistField)multilistField).GetItems();
         }
         return new Item[0];
      }
   }

5. Finally, you will need to have the extended version of the WorkflowState template where you can set the needed flags and settings:

image

I created my own “Semi Final State” template which inherits from standard /System/Workflow/State template.

image

This solution has been provided to a customer, and appears to be working fine in production for a couple of months now.

Let me know what you think!

11 comments:

Fireblayde said...

Very nice. Working on the same thing now and I'm very surprised this is not already built into sitecore

Wooinvi said...

Thank you very much. It's best solution for me and also sitecore developer on the world.

Unknown said...

Is this Part 2 work in concert with Part 1 or is it a mutually exclusive option?

Unknown said...

Part 1 and 2 are two separate solutions for the same problem.
Sorry about the confusion.

Chris G said...

Hey Alex, did you reference a class for your "Publish To QA" action in your QA State? Since this is all automatic I'm guessing one should just programically move the user from draft to QA and the publish happens automagically via your target settings.

Chris G said...

Hey Alex, Did you use any code for your "Publish To QA" action? All I need todo is move the user to the QA state and the rest is executed behind the scenes?

Mikhail said...

Hi Alex, I have the same question as Chris - Did you use any code for "Publish To QA"
Thanks

Andy Uzick said...

I stole this idea from you a long time ago :)

However, I extended the workflow to create an "is final for" checkboxlist. This allows a workflow state to be "final" for any - or multiple - targets (e.g. the pre-publish QA).

Anil Mall said...

Hi Alex,
I am facing problem in this approach. When I apply the workflow without adding the new version of item and make some changes and approve this item from workbox. The content of this page get disappeared from the site.
I have observed the the item version get removed from web database of final prod but not from QA.

Please suggest what and where I have to modify in current code?

Unknown said...

Hi Alex, does it work with Sitecore 6.6??

because I've already implemented the code but it doesn't work!?

thanks in advance
patrik

Francesco Gallarotti said...

Alex, as usual a great solution. I was wondering how would you hook this up with regular publications so that a scheduled publication that uses PublishManager.PublishItem or PublishManager.PublishSmart would honor the new flag based on the publishing target?