Importing SharePoint List Data into Project Server 2007 Custom Fields

Summary: Learn how to use the programmability features of Microsoft Office Project Server 2007 and Windows SharePoint Services 3.0 to import SharePoint list data into an enterprise custom field. (40 printed pages)

MVP Icon Stephen C. Sanderlin, MSProjectExperts

September 2009

Applies to: Microsoft Office Project Server 2007, Microsoft Office SharePoint Server 2007, Windows SharePoint Services 3.0

Contents

  • Overview of Project Workspaces and Project Server Programmability Features

  • Scenario Overview

  • Design Decisions

  • Developing the ActiveIssuesHealth Event Handler

  • Avoiding Infinite Recursion

  • Deploying the ActiveIssuesHealth Event Handler

  • Conclusion

  • Additional Resources

You can download the code sample for this article from the MSProjectExperts site that describes the forthcoming book, Developer's Guide to Microsoft Project Server 2010.

Note

The book addresses both Project Server 2007 and Microsoft Project Server 2010. This article is adapted from a chapter in that book.

Overview of Project Workspaces and Project Server Programmability Features

In addition to its project management, resource planning, and reporting features, Microsoft Office Project Server 2007 encourages collaboration through a feature named Project Workspaces. This feature provides a central repository where project teams can share project artifacts and other information.

Project Server 2007 automatically collects data from three specialized lists in a project workspace: risks, issues, and deliverables. Users may access this data by using the Project Server data analysis and Reporting database features.

In this article, a hypothetical real-world scenario is used to demonstrate how the programmability features of Project Server 2007 and Windows SharePoint Services 3.0 enable you to customize and extend these products to fulfill the unique business needs of your customers.

Note

Development for this article was done on a server running Windows Server 2008 x64, Microsoft Office SharePoint Server 2007, and Project Server 2007 x64 Service Pack 2 with the June 2009 Cumulative Update, Microsoft Visual Studio 2008 Service Pack 1, and Visual Studio 2008 extensions for Windows SharePoint Services 3.0, v1.3 (VSeWSS 1.3) March 2009 CTP installed.

Scenario Overview

You are a senior developer in your organization's Microsoft Enterprise Project Management (EPM) practice. This practice designs, deploys, and customizes Project Server 2007 and related projects for both internal and external customers.

You are currently working on a Project Server 2007 deployment engagement for one of your company's largest clients, Contoso. Moments ago, you received an e-mail message from your team's technical lead informing you of a new "must-have" requirement from the customer.

Your team's project manager has promised Contoso's project sponsor a project custom field named Issue Health, which will contain a red, yellow, or green indicator based on the count of active issues in that project's workspace. The project manager mistakenly assumed that delivering the indicator field was simply a matter of creating a custom field by using a formula based on the Active Issues field.

Although you can easily display the issue count in the Project Center, you cannot reference it in a custom field formula to calculate a key performance indicator (KPI). As the team's senior developer, you must design a solution to fulfill this new requirement.

Design Decisions

According to the customer's requirements, you may not deliver any client-side solutions or any solution that involves manual population of the Issue Health field by Contoso's project managers.

Interacting with the Windows SharePoint Services 3.0 Web services and the Project Server Interface (PSI) by using Microsoft SQL Server Integration Services (SSIS) is possible, but you want to keep the solution simple by limiting the number of systems involved.

Direct exchange of database data between two systems by using SSIS is possible, but Microsoft specifically does not recommend interacting directly with the Windows SharePoint Services 3.0 and Project Server 2007 databases other than the Reporting database (RDB).

Understanding the Eventing Architecture

The Project Server 2007 eventing architecture offers two primary types of events: PSI-based events and RDB-based events. The server raises PSI-based events for many, but not all, PSI methods, and raises RDB-based events during the creation, modification, and deletion of data within the RDB.

The eventing architecture further subdivides PSI-based events into two types: pre-events and post-events. The server raises pre-events immediately before saving data to the database, and raises post-events immediately after saving data to the database.

Note

All RDB-based events are post-events.

You may not cancel either category of post-event, but you may abort a database save operation by cancelling a pre-event. The eventing architecture also automatically cancels a pre-event if an event handler throws an unhandled exception.

For more information about the Project Server 2007 eventing architecture, see Project Server Events.

Selecting a Server-Side Event for Customization

One of your most important tasks when designing a custom event handler is the selection of an event. Although the rich eventing architecture of the PSI may seem overwhelming at first, remember that events correspond to specific operations or events within the platform. In some cases, you may need to isolate a particular operation or event to narrow down your choices. For example, if your requirement is to simply make changes to a project's custom fields when a user saves it to the server, you could potentially choose any one of the following events:

  • OnSaved

  • OnUpdating

  • OnUpdated

  • OnPublishing

  • OnPublished

  • OnCheckIn

To reduce how many choices you have, you should clarify your requirements to align with a particular operation. For example, if your customer is willing to update a project's custom fields only on a publish operation, you have reduced your choices to either the OnPublishing event or the OnPublished event. As a general rule, you should use a pre-event only in situations where you may need to cancel a write operation to a database.

In some cases, you may discover that no available event fits your needs. For example, updating a project's custom fields before the server commits a user's changes to the Draft database is not possible, because there is no OnSaving event. In this situation, you need to either adjust your requirements or design an alternative solution.

Remember that if you change to a different event in the middle of your development cycle, you may need to refactor significant portions of your code depending on the differences between each event's EventArgs parameter.

ActiveIssuesHealth High-Level Design

Contoso's requirements do not dictate that you should prevent a project from being published if the count of active issues cannot be obtained, only that this information should be updated each time the project is published. Therefore, the ActiveIssuesHealth event handler will use the OnPublished event.

Figure 1. ActiveIssuesHealth solution high-level workflow

ActiveIssuesHealth solution high-level workflow

As shown in Figure 1, the event handler executes these high-level tasks:

  1. Retrieve the project workspace URL from the PSI.

  2. Retrieve and count the active issues within the project workspace.

  3. Update and publish the project by using the PSI.

Developing the ActiveIssuesHealth Event Handler

With the design finalized, you can start developing the custom event handler. You use the following procedures:

  1. Create the event handler project and set references.

  2. Override the event's base method.

  3. Develop an exception expander for PSI SoapExceptions.

  4. Develop a class to store Project Web Access (PWA) site information.

  5. Develop a class to store workspace information.

  6. Retrieve the project's workspace URL and the issues list URL.

  7. Store and retrieve the event handler's settings.

  8. Retrieve the count of active issues.

  9. Retrieve the GUID of the target custom field.

  10. Create an update payload.

  11. Create a ProjectDerived class for impersonation.

  12. Retrieve the current checkout's SessionUID.

  13. Update and publish the project.

  14. Create an installer to register with Project Server.

  15. Create the Stsadm extension project and set references.

  16. Develop the SetSspName Stsadm extension.

  17. Develop the SetActiveStatusValue Stsadm extension.

  18. Develop the SetTargetCustomField Stsadm extension.

  19. Create the VSeWSS project and set references.

  20. Construct the solution package (.wsp file) for deployment to Windows SharePoint Services 3.0 or SharePoint Server.

    Note

    This solution requires the Microsoft Office Project Server Events Service identity to have administrator access to Project Server. You should also add this account to the Shared Services Provider (SSP) of Project Server as a process account.

To begin, create the event handler and set references.

Procedure 1. Create the event handler project and set references

  1. Open Visual Studio 2008.

  2. Create a Microsoft .NET Framework 3.5 Class Library project named ActiveIssuesHealth.

  3. Edit the properties for the ActiveIssuesHealth project, enable assembly signing, and create a keyfile.

  4. In Solution Explorer, rename Class1.cs to ActiveIssuesHealthHandler.cs.

  5. In the Add Reference dialog box, add references to the Microsoft.Office.Project.Server.Events.Recievers.dll assembly and the Microsoft.Office.Project.Server.Library.dll assembly. Following is the default location:

    %ProgramFiles%\Microsoft Office Servers\12.0\Bin\

    NoteNote
    If you are developing on a 64-bit server, you should manually browse to drive\Program Files instead of using the %ProgramFiles% environment variable. Because Visual Studio 2008 is a 32-bit application running under the WOW64 x86 emulator, %ProgramFiles% resolves to drive\Program Files (x86) in a 64-bit environment.
  6. Add a reference to Microsoft.SharePoint.dll. Following is the default file location:

    %ProgramFiles%\Common Files\Microsoft Shared\Web Server Extensions\12\ISAPI\

  7. In the Add Reference dialog box, add a reference to System.Web.Services.dll, listed under the .NET tab.

  8. Add the following lines to the global references section of ActiveIssuesHealthHandler.cs.

  9. In the Add Web Reference dialog box, add a Web reference to the WssInterop PSI Web service named WssInteropSvc. Following is the default Web service location:

    http://servername/instancename/_vti_bin/psi/wssinterop.asmx

    NoteNote
    To access the Add Web Reference dialog box in Visual Studio 2008, in the Solution Explorer Add Service Reference dialog box, click Advanced.
  10. Add a Web reference to the Project PSI Web service named ProjectSvc. Following is the default Web service location:

    http://servername/instancename/_vti_bin/psi/project.asmx

  11. Add a Web reference to the CustomFields PSI Web service named CustomFieldsSvc. Following is the default Web service location:

    http://servername/instancename/_vti_bin/psi/customfields.asmx

  12. Add a Web reference to the CustomFields PSI Web service named EventsSvc. Following is the default Web service location:

    http://servername/instancename/_vti_bin/psi/events.asmx

  13. Add the following global constants to the ActiveIssuesHealthHandler class.

The Microsoft.Office.Project.Server.Library namespace contains enumerations and classes that are used by the PSI and event base methods. The Microsoft.Office.Project.Server.Events namespace contains delegates, interfaces, and classes related to events. The Microsoft.SharePoint namespace contains objects that enable you to interact with SharePoint.

For more information about finding and using PSI Web services, see Finding the PSI Web Services and Using the PSI in Visual Studio.

For more information about the assemblies you added in this procedure, see Microsoft.Office.Project.Library Namespace, Microsoft.Office.Project.Server.Events Namespace, and Microsoft.SharePoint Namespace.

Procedure 2. Override the event's base method

  1. Modify the ActiveIssuesHealthHandler class to inherit the ProjectEventReceiver base abstract event receiver class.

    For more information about the ProjectEventReceiver class, see ProjectEventReceiver Class (Microsoft.Office.Project.Server.Events).

  2. Create an override method for the OnPublished base method.

    For more information about the OnPublished event, see ProjectEventReceiver.OnPublished Method (Microsoft.Office.Project.Server.Events).

  3. Delete the base.OnPublished(contextInfo, e); statement from the OnPublished method.

  4. Add a try…catch block to the OnPublished method.

When the PSI encounters an error, it returns a SoapException exception instead of the data you requested. The PSI encodes specific error codes and more detailed information about the error within the SoapException object. To extract this data, use the PSClientError class.

Note

In some cases, the PSClientError class does not extract all of the detailed error information from the SoapException object. Additional error information can be found in the SoapException.InnerXML property. The ExpandPsiException method presented in Procedure 3 always extracts the contents of the exception's InnerXML property to compensate for this limitation in PSClientError. But, this approach may sometimes result in significantly more complex error messages. For more information, see Resource.CheckOutResources Method (WebSvcResource).

Procedure 3. Create an exception expander for PSI SoapExceptions

  1. In ActiveIssuesHealthHandler.cs, create a method named ExpandPsiException.

  2. Develop the ExpandPsiException method. This method uses the PSClientError class to extract detailed error information from a SoapException exception thrown by the PSI.

    For more information about the PSClientError class, see PSClientError Class (Microsoft.Office.Project.Server.Library).

  3. Add the following line to the OnPublished method's catch block.

Before you can do any work with the PSI, you need to devise some way of obtaining and storing the URL of the Project Server instance that invoked your event handler. Because you use impersonation later in this article, it makes sense to also grab the hostname and protocol.

Although neither of the parameters for the OnPublished method exposes any of this information, the ProjectPostPublishEventArgs class does provide a SiteGuid property. You can use this GUID to create an instance of the SPSite class, which exposes all of this information.

Procedure 4. Develop a class to store PWA site information

  1. In Solution Explorer, add a new class named PwaSiteInfo.cs to the project.

  2. Modify the class declaration of PwaSiteInfo.cs to add an internal access modifier.

  3. Add the following line to the global references section of the class.

  4. Add three automatic properties to the class: one to store the hostname, one to store the protocol, and one to store the PWA URL.

  5. Add the following constructor to the class. This constructor uses the SiteGuid from ProjectPostPublishEventArgs to create an SPSite object from which it populates the new instance of PwaSiteInfo with the PWA site's hostname, protocol, and URL.

    NoteNote
    Many of the methods in this article use classes that implement IDisposable. Although the common language runtime (CLR) contains garbage collection features, Microsoft best practices state that you should not rely upon the garbage collector to release memory allocated to objects that implement IDisposable in a timely manner, if at all. For more information, see Best Practices: Using Disposable Windows SharePoint Services Objects and IDisposable Interface. For more information about the SPSite class, see SPSite Class (Microsoft.SharePoint).
  6. Add a call to the PwaSiteInfo constructor in the OnPublished method's try block.

Now you need some way to store the workspace name and the Issues list name. Although you could easily use one of the various dictionary collections, there is a chance that in the future you may need to store additional data pertaining to a project workspace and its contents. You may even need to implement one or more methods to process this information in some way. Using a class to store this information is the better solution.

Procedure 5. Develop a class to store workspace information

  1. In Solution Explorer, add a new class named ProjectWssInfo.cs to the project.

  2. Modify the class declaration of ProjectWssInfo.cs to add an internal access modifier.

  3. Add two automatic properties to the class: one to store the name of the Issues list, and another to store the name of the project workspace.

  4. Add an empty default constructor to the class.

Next, develop a method that retrieves URLs for the project's workspace and Issues list. This information is easily retrievable from the WssInterop PSI Web service by using the ReadWssInfo method. Because you are interested only in the names of the workspace and the workspace's Issues list, you need to change the URLs returned by ReadWssInfo into the desired format.

Procedure 6. Retrieve the project's workspace and Issues list URLs

  1. In ActiveIssuesHealthHandler.cs, create a method named GetPwsInfo.

  2. Develop the GetPwsInfo method by using the WssInterop PSI Web service. This method retrieves the workspace and Issues list URLs by calling ReadWssData for the project GUID that raised the OnPublished event. These URLs are then stripped down to the workspace and Issues list names and returned in the ProjectWssInfo class that you created earlier.

    // Declarations.
    ProjectWssInfo _info = new ProjectWssInfo();
    WssInteropSvc.WssInterop _wssInteropPsi = null;
    WssInteropSvc.ProjectWSSInfoDataSet _wssInfoDs = null;
    
    try
    {
        if (String.IsNullOrEmpty(pwaUrl))
            throw new ArgumentNullException("pwaUrl");
    
        if (projectUid == Guid.Empty)
            throw new ArgumentException("Empty Guid", "projectUid");
    
        // Initialize and configure Web reference.
        _wssInteropPsi = new WssInteropSvc.WssInterop();
        _wssInteropPsi.Credentials =
            CredentialCache.DefaultCredentials;
        _wssInteropPsi.Url = pwaUrl + _wssInteropSvcPath;
    
        _wssInfoDs = _wssInteropPsi.ReadWssData(projectUid);
    
        if (_wssInfoDs == null)
        {
            throw new Exception(
                "Unable to retrieve workspace for project " +
                projectUid);
        }
    
        if (_wssInfoDs.ProjWssInfo.Count < 1)
        {
            throw new Exception("No WSS information for project " +
                                projectUid);
        }
    
        if (_wssInfoDs.ProjWssInfo[0].PROJECT_WORKSPACE_URL.GetType() == 
            typeof(DBNull))
        {
            throw new Exception(
                "No workspace found for project " + 
                projectUid);
        }
    
        if (_wssInfoDs.ProjWssInfo[0].PROJECT_ISSUES_URL.GetType() ==
            typeof(DBNull))
        {
            throw new Exception("No issues list found for project " +
                                projectUid);
        }
    
        string _workspaceName =
            _wssInfoDs.ProjWssInfo[0].PROJECT_WORKSPACE_URL;
    
        string _issuesListName =
            _wssInfoDs.ProjWssInfo[0].PROJECT_ISSUES_URL;
    
        if (String.IsNullOrEmpty(_workspaceName))
        {
            throw new Exception("No workspace URL for project " +
                                projectUid);
        }
    
        if (String.IsNullOrEmpty(_issuesListName))
        {
            throw new Exception("No issues list URL for project " +
                                projectUid);
        }
    
        // Strip the URLs down to only names.
        // Strip the beginning of the URL.
        _workspaceName = 
            _workspaceName.Remove(0, 
                (_workspaceName.LastIndexOf("/") + 1));
    
        // Remove /AllItems.aspx from the Issues list URL.
        _issuesListName = _issuesListName.Remove(
            _issuesListName.IndexOf("/AllItems.aspx"), 14);
    
        // Strip everything before the list name.
        _issuesListName = 
            _issuesListName.Remove(0, 
                (_issuesListName.LastIndexOf("/") + 1));
    
        _info.IssueListName = _issuesListName;
        _info.WorkspaceName = _workspaceName;
    
        return _info;
    }
    
    finally
    {
        if (_wssInteropPsi != null) _wssInteropPsi.Dispose();
        if (_wssInfoDs != null) _wssInfoDs.Dispose();
    }

    For more information about the WssInterop class, see WssInterop Class (WebSvcWssInterop).

  3. In the OnPublished method's try block, add a call to GetPwsInfo.

Although many developers feel that the easiest way to store settings is through some sort of external configuration file, I prefer to use the SharePoint SPPropertyBag class. The PropertyBag is a hashtable that SharePoint stores in the Configuration database as a persisted object. This approach eliminates the burden of synchronizing configuration files across servers in a farm. You store three settings:

  • ActiveStatusValue, which indicates the status value considered "active."

  • SspName, which you use when updating the project via impersonation.

  • TargetCustomField, which indicates the project number custom field you update with the active issues count.

Procedure 7. Store and retrieve the event handler's settings

  1. In Solution Explorer, add a new class named HandlerSettings.cs to the project.

  2. Add the following lines to the global references section of the class.

  3. Modify the class declaration of HandlerSettings.cs to add a public access modifier.

  4. Add three automatic properties to the class: one to store the value that qualifies an issue as active, one to store the SSP name, and another to store the name of the custom field the handler should populate with the count of active issues.

  5. Add an empty default constructor to the class.

  6. Add the following constants to the HandlerSettings class.

  7. Create a method named GetKeyValue.

  8. Develop the GetKeyValue method. This method uses an SPWeb object to retrieve values from and return values to the PWA SPPropertyBag class.

    For more information about the SPPropertyBag class, see SPPropertyBag Class (Microsoft.SharePoint.Utilities). For more information about the SPWeb class, see SPWeb Class (Microsoft.SharePoint).

  9. Create a new method named GetHandlerSettings.

  10. Develop the GetHandlerSettings method. This method retrieves multiple settings by using the GetKeyValue method and bundles them into a HandlerSettings object for return.

  11. Create a new method named SetKeyValue.

  12. Develop the SetKeyValue method. This method uses an SPWeb object to set key-value pairs on the PWA Web's SPPropertyBag class.

  13. Create a new method named SetHandlerSettings.

  14. Develop the SetHandlerSettings method. This method instantiates the necessary SharePoint classes and encapsulates the SetKeyValue method that you just created.

  15. In the OnPublished method's try block, add a call to GetHandlerSettings.

Next, you must retrieve a count of active issues from the project's workspace. First, load the project workspace by using the SPWeb class. Then, construct a CAML query by using the SPQuery class to filter out any issues with a status other than the designated active status. After constructing the CAML query, pass it to the SPList.GetItems method and return the number of results.

Procedure 8. Retrieve the count of active issues

  1. In ActiveIssuesHealthHandler.cs, create a method named GetActiveIssuesHealth.

  2. Develop the GetActiveIssuesHealth method. This method creates an SPWeb by using the workspace name retrieved earlier, builds an SPQuery object to retrieve only active issues, and returns a count.

    For more information about the SPQuery class, see SPQuery Class (Microsoft.SharePoint).

  3. In the OnPublished method's try block, add a call to GetActiveIssuesHealth.

Instead of requiring users to designate a target custom field by using its unique identifier, the HandlerSettings class that you developed earlier enables them to use its much friendlier name string. However, the PSI datasets relate custom fields with entities such as projects and resources only by using their unique identifier. This means that you must develop a method that translates a custom field's name into its unique identifier.

Fortunately, the ReadCustomFields method in the CustomFields Web service accepts a filter parameter that limits the amount of data it returns. You can use this functionality to return data for only custom fields with the desired name.

Procedure 9. Retrieve the Guid of the target custom field

  1. In ActiveIssuesHealthHandler.cs, create a method named GetCustomFieldGuid.

  2. Develop the GetCustomFieldGuid method by using the CustomFields PSI Web service. This method uses a filter parameter to find a custom field by name and return its GUID.

    // Declarations.
    CustomFieldsSvc.CustomFields _customFieldsPsi = null;
    CustomFieldsSvc.CustomFieldDataSet _fieldDs = 
        new CustomFieldsSvc.CustomFieldDataSet();
    
    try
    {
        if (String.IsNullOrEmpty(pwaUrl))
            throw new ArgumentNullException("pwaUrl");
    
        if (String.IsNullOrEmpty(fieldName))
            throw new ArgumentNullException("fieldName");
    
        // Initialize and configure Web reference.
        _customFieldsPsi = new CustomFieldsSvc.CustomFields();
        _customFieldsPsi.Credentials = 
            CredentialCache.DefaultCredentials;
        _customFieldsPsi.Url = pwaUrl + _wssInteropSvcPath;
    
        // Set up objects and strings needed for the filter.
        EntityCollection _entityType = new EntityCollection();
        Filter _cfFilter = new Filter();
        Filter.FieldOperationType _equal = 
            Filter.FieldOperationType.Equal;
    
        string _entityTypeColumnName =
            _fieldDs.CustomFields.MD_PROP_TYPE_ENUMColumn.ColumnName;
        string _entityUidColumnName = 
            _fieldDs.CustomFields.MD_ENT_TYPE_UIDColumn.ColumnName;
        string _nameColumnName =
            _fieldDs.CustomFields.MD_PROP_NAMEColumn.ColumnName;
        string _tableName = _fieldDs.CustomFields.TableName;
        string _uidColumnName = 
            _fieldDs.CustomFields.MD_PROP_UIDColumn.ColumnName;
    
        // Compose the filter.
        _cfFilter.FilterTableName = _tableName;
        _cfFilter.Fields.Add(new Filter.Field(
                                 _tableName, _nameColumnName));
        _cfFilter.Fields.Add(new Filter.Field(_uidColumnName));
        _cfFilter.Criteria =
            new Filter.LogicalOperator(
                Filter.LogicalOperationType.And,
                new Filter.FieldOperator(_equal, _entityUidColumnName,
                                         entityType),
                new Filter.FieldOperator(_equal,
                                         _nameColumnName, fieldName),
                new Filter.FieldOperator(_equal,
                                         _entityTypeColumnName, 
                                         fieldType));
    
        _fieldDs = _customFieldsPsi.ReadCustomFields(
            _cfFilter.GetXml(), false);
    
        if (_fieldDs == null)
        {
            throw new Exception("Custom field '" + fieldName +
                "' of type '" + (CustomField.Type)fieldType + 
                "' could not be found.");
        }
    
        // One result only should be returned.
        if (_fieldDs.CustomFields.Rows.Count == 0)
        {
            throw new Exception("Custom field '" + fieldName +
                "' of type '" + (CustomField.Type)fieldType +
                "' could not be found.");
        }
    
        if (_fieldDs.CustomFields.Rows.Count > 1)
        {
            throw new Exception("Multiple entries for " + 
                "custom field '" + fieldName +
                "' of type '" + (CustomField.Type)fieldType +
                "' were found.");
        }
    
        return _fieldDs.CustomFields[0].MD_PROP_UID;
    }
    
    finally
    {
        if (_customFieldsPsi != null) _customFieldsPsi.Dispose();
        if (_fieldDs != null) _fieldDs.Dispose();
    }

    For more information about the CustomFields Web service, see CustomFields Class (WebSvcCustomFields). For more information about filter parameters, see How to: Use a Filter Parameter with PSI Methods.

  3. In the OnPublished method's try block, add a call to GetCustomFieldGuid.

Next, create a method to generate an update payload for transmission to Project Server.

Procedure 10. Create an update payload

  1. In ActiveIssuesHealthHandler.cs, create a method named CreateUpdate.

  2. Develop the CreateUpdate method by using the Project PSI Web service. This method retrieves the current custom field data for the project, verifies whether the custom field is already defined, and either adds the custom field to the project with the retrieved active issue count or updates the existing custom field with the new active issue count. The dataset containing the modifications is returned by the method.

    if (String.IsNullOrEmpty(pwaUrl))
        throw new ArgumentNullException("pwaUrl");
    
    if (projectUid == Guid.Empty)
        throw new ArgumentException("Empty Guid", "projectUid");
    
    if (targetFieldUid == Guid.Empty)
        throw new ArgumentException("Empty Guid",
                                    "targetFieldUid");
    
    // Declarations.
    ProjectSvc.Project _projectPsi = null;
    ProjectSvc.ProjectDataSet _projCfDs = null;
    ProjectSvc.ProjectDataSet _updateDs = null;
    
    try
    {
        if (String.IsNullOrEmpty(pwaUrl))
            throw new ArgumentNullException("pwaUrl");
    
        if (projectUid == Guid.Empty)
            throw new ArgumentException("Empty Guid", "projectUid");
    
        // Initialize and configure Web reference.
        _projectPsi = new ProjectSvc.Project();
        _projectPsi.Credentials =
            CredentialCache.DefaultCredentials;
        _projectPsi.Url = pwaUrl + _projectSvcPath;
    
    
        // Retrieve project custom fields to ensure that
        // we are not duplicating a custom field row.
        _projCfDs = _projectPsi.ReadProjectEntities
            (projectUid, 32, ProjectSvc.DataStoreEnum.WorkingStore);
    
        if (_projCfDs == null)
        {
            throw new Exception("Unable to retrieve project " +
                                projectUid);
        }
    
        // Make a copy of the retrieved ProjectDataSet for update.
        _updateDs = _projCfDs.Copy() as ProjectSvc.ProjectDataSet;
    
        if (_updateDs == null)
        {
            throw new Exception("Unable to retrieve project " +
                                projectUid);
        }
    
        if (_updateDs.ProjectCustomFields.Count > 0)
        {
            foreach (
                ProjectSvc.ProjectDataSet.ProjectCustomFieldsRow _row
                in _updateDs.ProjectCustomFields)
            {
                if (_row.MD_PROP_UID == targetFieldUid)
                {
                    // Avoid recursion loops by terminating if
                    // the current value is unchanged from
                    // the stored value.
                    if (_row.NUM_VALUE == activeIssuesHealth)
                        return null;
    
                    _row.NUM_VALUE = activeIssuesHealth;
                    
                    return _updateDs;
                }
            }
        }
    
        ProjectSvc.ProjectDataSet.ProjectCustomFieldsRow _newRow =
            _updateDs.ProjectCustomFields.NewProjectCustomFieldsRow();
    
        _newRow.CUSTOM_FIELD_UID = Guid.NewGuid();
        _newRow.MD_PROP_UID = targetFieldUid;
        _newRow.NUM_VALUE = activeIssuesHealth;
        _newRow.PROJ_UID = projectUid;
        _updateDs.ProjectCustomFields.AddProjectCustomFieldsRow(
            _newRow);
    
        return _updateDs;
    }
    
    finally
    {
        if (_projectPsi != null) _projectPsi.Dispose();
        if (_projCfDs != null) _projCfDs.Dispose();
        if (_updateDs != null) _updateDs.Dispose();
    }
    NoteNote
    You must always verify that a custom field is not already defined on a project before you add a new custom field row to the project's ProjectCustomFields table.

    For more information about the rows in the ProjectCustomFields table, see ProjectDataSet.ProjectCustomFieldsRow Class (WebSvcProject).

Because the OnPublished event was triggered by a user's publish, you want to impersonate that user when performing the project update and republish.

For more information about impersonating another user with the PSI, see Using Impersonation in Project Server.

Procedure 11. Create a ProjectDerived class for impersonation

  1. In Solution Explorer, add a new class named ProjectDerived.cs to the project.

  2. Replace the contents of ProjectDerived.cs with the following code.

Before you can update and publish the project, you need to determine the session identifier of the user's current checkout. You use this identifier when updating the project.

Procedure 12. Retrieve the current checkout's session_uid

  1. In ActiveIssuesHealthHandler.cs, create a method named GetSessionUid.

  2. Develop the GetSessionUid method by using the Project PSI Web service. This method retrieves the current project's information and extracts the session identifier.

    For more information about the session identifier, see ProjectDataSet.ProjectRow.PROJ_SESSION_UID Property (WebSvcProject).

Now that you have an update dataset, transmit this data to the PSI.

Procedure 13. Update and publish the project

  1. In ActiveIssuesHealthHandler.cs, create a method named UpdateProject.

  2. Develop the UpdateProject method. If the project was not checked out when Project Server invoked the event handler, you must check it out by using the CheckOutProject method before you can update it. Then, transmit the update for enqueueing by using impersonation and the QueueUpdateProject method. After the update payload is transmitted, republish the project by using the QueuePublish method. Finally, if the project was not checked out when Project Server invoked the event handler, you must check it back in by using the QueueCheckInProject method.

    if (String.IsNullOrEmpty(sspName))
        throw new ArgumentNullException("sspName");
    
    if (String.IsNullOrEmpty(userName))
        throw new ArgumentNullException("userName");
    
    if (userUid == Guid.Empty)
        throw new ArgumentException("Empty Guid", "userUid");
    
    if (siteUid == Guid.Empty)
        throw new ArgumentException("Empty Guid", "siteUid");
    
    if (String.IsNullOrEmpty(lcid))
        throw new ArgumentNullException("lcid");
    
    if (String.IsNullOrEmpty(hostname))
        throw new ArgumentNullException("hostname");
    
    if (String.IsNullOrEmpty(protocol))
        throw new ArgumentNullException("protocol");
    
    if (projectUid == Guid.Empty)
        throw new ArgumentException("Empty Guid", "projectUid");
    
    // This is the second half of the anti-infinite loop logic.
    if (updateDataSet == null)
        return;
    
    if (updateDataSet.ProjectCustomFields.Rows.Count < 1)
        throw new ArgumentException("Empty Update", "updateDataSet");
    
    // Declarations.
    ProjectDerived _projectImp = null;
    
    try
    {
        // Initialize and configure Web reference.
        _projectImp = new ProjectDerived();
        _projectImp.Credentials = CredentialCache.DefaultCredentials;
    
        string _tempUrl;
    
        // Replace the port placeholder.
        if (protocol.Equals("http:"))
        {
            _tempUrl = protocol + "//" + hostname + ":" +
                              _sspHttpPort + "/" + sspName +
                              _projectImpPath;
        }
    
        else
        {
            _tempUrl = protocol + "//" + hostname + ":" +
                              _sspHttpsPort + "/" + sspName + 
                              _projectImpPath;
        }
    
        _projectImp.Url = _tempUrl;
    
        ProjectDerived.SetImpersonationContext(isWindowsUser,
                                               userName, userUid, 
                                               Guid.NewGuid(), 
                                               siteUid, lcid);
    
        bool _wasProjectCheckedOut = true;
    
        // Retrieve the sessionUid if checked out or 
        // Guid.Empty if not.
        Guid _sessionUid = GetSessionUid(pwaUrl, projectUid);
    
        // Determine whether the project was checked out.
        if (_sessionUid == Guid.Empty)
        {
            _wasProjectCheckedOut = false;
            _sessionUid = Guid.NewGuid();
    
            _projectImp.CheckOutProject(projectUid, _sessionUid,
                                        _sessionDesc);
        }
    
        Guid _jobUid = Guid.NewGuid();
    
        // Queue the update.
        _projectImp.QueueUpdateProject(_jobUid, _sessionUid,
                                       updateDataSet, false);
    
        // Republish the project.
        _jobUid = Guid.NewGuid();
        _projectImp.QueuePublish(_jobUid, projectUid, false, null);
    
        // If the project was not checked out, check it in
        if (!_wasProjectCheckedOut)
        {
            _jobUid = Guid.NewGuid();
            _projectImp.QueueCheckInProject(_jobUid, projectUid,
                false, _sessionUid, _sessionDesc);
        }
    }
    
    finally
    {
        if (_projectImp != null) _projectImp.Dispose();
    }

    For more information about the methods used in this procedure, see Project Methods (WebSvcProject).

  3. In the OnPublished method's try block, add a call to UpdateProject.

    To use impersonation, you must know whether the user you are impersonating is a Windows Authentication user or not. Although the ContextInfo parameter passed into the event handler contains an IsWindowsUser property, I have observed some erratic behavior with this value. To avoid a call to the Resource PSI Web service, check the UserName property of the ContextInfo parameter. If it starts with a prefix associated with a custom MembershipProvider, the user is not a Windows Authentication user.

The functionality to retrieve and synchronize the active issue count is complete. However, because you are packaging this code as a SharePoint Feature, you should create a feature receiver to automate installation of the event handler to Project Server.

Procedure 14. Create an installer to register with Project Server

  1. In Solution Explorer, add a new class named ActiveIssuesHealthInstaller.cs to the ActiveIssuesHealth project.

  2. Add the following line to the global references section of ActiveIssuesHealthInstaller.cs.

  3. Add the following global constants to the ActiveIssuesHealthInstaller class.

  4. Modify the ActiveIssuesHealthInstaller class to inherit the SPFeatureReceiver base abstract class.

    For more information about the SPFeatureReceiver class, see SPFeatureReceiver Class (Microsoft.SharePoint).

  5. Remove the throw new NotImplementedException() statement from all four methods.

  6. Develop the FeatureActivated method. This method uses the Events PSI Web service to register the ActiveIssuesHealth event handler with the Project Server OnPublished event.

    // Declarations.
    Assembly _thisAssembly = null;
    EventsSvc.Events _eventsPsi = null;
    EventsSvc.EventHandlersDataSet _handlersDs = null;
    EventsSvc.EventHandlersDataSet _handlersUpdateDs = null;
    SPWeb _pwaWeb = null;
    
    try
    {
        // Initializations.
        _pwaWeb = properties.Feature.Parent as SPWeb;
        _eventsPsi = new EventsSvc.Events();
        _thisAssembly = Assembly.GetExecutingAssembly();
    
        if (_pwaWeb == null)
            throw new Exception("Unable to load SPWeb.");
    
        // Initialize and configure Web reference.
        _eventsPsi = new EventsSvc.Events();
        _eventsPsi.Credentials =
            CredentialCache.DefaultCredentials;
        _eventsPsi.Url = _pwaWeb.Url + _eventsSvcPath;
    
        // Get a list of event handlers.
        _handlersDs =
            _eventsPsi.ReadEventHandlerAssociationsForEvent(
                EventsSvc.PSEventID.ProjectPublished);
    
        _handlersUpdateDs = 
            _handlersDs.Copy() as EventsSvc.EventHandlersDataSet;
    
        bool _handlerAlreadyExists = false;
    
        // Ensure that the event handler does not already exist.
        foreach (
            EventsSvc.EventHandlersDataSet.EventHandlersRow _row
            in _handlersUpdateDs.EventHandlers)
        {
            if (_row.Name == _handlerName)
                _handlerAlreadyExists = true;
        }
    
        if (!_handlerAlreadyExists)
        {
            _handlersUpdateDs.EventHandlers.AddEventHandlersRow(
                Guid.NewGuid(), _handlerName, _thisAssembly.FullName,
                (_handlerName + ".ActiveIssuesHealthHandler"),
                (int)EventsSvc.PSEventID.ProjectPublished,
                "ActiveIssuesHealth Handler", 0);
    
            // Associate the event handler.
            _eventsPsi.CreateEventHandlerAssociations(
                _handlersUpdateDs);
    
            // Create custom settings in Web's SPPropertyBag.
            HandlerSettings.SetHandlerSettings(
                _pwaWeb.Site.ID,
                HandlerSettings.ActiveStatusValueKey, "(1) Active");
    
            HandlerSettings.SetHandlerSettings(
                _pwaWeb.Site.ID,
                HandlerSettings.SspNameKey, "EPM_SSP");
    
            HandlerSettings.SetHandlerSettings(
                _pwaWeb.Site.ID,
                HandlerSettings.TargetCustomFieldKey, "Active Issues Health");
        }
    }
    
    finally
    {
        if (_eventsPsi != null) _eventsPsi.Dispose();
        if (_handlersDs != null) _handlersDs.Dispose();
        if (_handlersUpdateDs != null) _handlersUpdateDs.Dispose();
        if (_pwaWeb != null) _pwaWeb.Dispose();
    }

    For more information about the Events PSI Web service, see Events Class (WebSvcEvents).

    For more information about the Assembly class used in this procedure, see Assembly Class.

  7. Develop the FeatureDeactivated method. This method uses the Events PSI Web service to remove the registration of the ActiveIssuesHealth event handler from the Project Server OnPublished event.

    // Declarations.
    EventsSvc.Events _eventsPsi = null;
    EventsSvc.EventHandlersDataSet _handlersDs = null;
    EventsSvc.EventHandlersDataSet _handlersUpdateDs = null;
    SPWeb _pwaWeb = null;
    
    try
    {
        // Initializations.
        _pwaWeb = properties.Feature.Parent as SPWeb;
        _eventsPsi = new EventsSvc.Events();
        Guid[] _eventHandlerGuids = new Guid[1];
    
        if (_pwaWeb == null)
            throw new Exception("Unable to load SPWeb.");
    
        // Initialize and configure Web reference.
        _eventsPsi = new EventsSvc.Events();
        _eventsPsi.Credentials =
            CredentialCache.DefaultCredentials;
        _eventsPsi.Url = _pwaWeb.Url + _eventsSvcPath;
    
        // Get a list of event handlers.
        _handlersDs =
            _eventsPsi.ReadEventHandlerAssociationsForEvent(
                EventsSvc.PSEventID.ProjectPublished);
    
        _handlersUpdateDs = 
            _handlersDs.Copy() as EventsSvc.EventHandlersDataSet;
    
        // Ensure that the event handler does not already exist.
        foreach (
            EventsSvc.EventHandlersDataSet.EventHandlersRow _row
            in _handlersUpdateDs.EventHandlers)
        {
            if (_row.Name == _handlerName)
            {
                _eventHandlerGuids[0] = _row.EventHandlerUid;
                _eventsPsi.DeleteEventHandlerAssociations(
                    _eventHandlerGuids);
            }
        }
    
        // Create custom settings in Web's SPPropertyBag.
        HandlerSettings.SetHandlerSettings(
            _pwaWeb.Site.ID,
            HandlerSettings.ActiveStatusValueKey, "");
    
        HandlerSettings.SetHandlerSettings(
            _pwaWeb.Site.ID,
            HandlerSettings.SspNameKey, "");
    
        HandlerSettings.SetHandlerSettings(
            _pwaWeb.Site.ID,
            HandlerSettings.TargetCustomFieldKey, "");
    }
    
    finally
    {
        if (_eventsPsi != null) _eventsPsi.Dispose();
        if (_handlersDs != null) _handlersDs.Dispose();
        if (_handlersUpdateDs != null) _handlersUpdateDs.Dispose();
        if (_pwaWeb != null) _pwaWeb.Dispose();
    }

Upon completion of the ActiveIssuesHealthHandlerInstaller class, the ActiveIssuesHealth project is complete. Your remaining tasks include creating three Stsadm extensions to enable configuration of the event handler's settings, and packaging the solution for deployment.

Procedure 15. Create the Stsadm extension project and set references

  1. Add a new project of type Class Library named ActiveIssuesHealthConfig to the solution.

  2. Rename Class1.cs to SetSspName.cs.

  3. Edit the properties for the ActiveIssuesHealthConfig project, enable assembly signing, and create a new keyfile.

  4. In Solution Explorer, in the Add Reference dialog box, add a reference to Microsoft.SharePoint.dll. Following is the default file location:

    %ProgramFiles%\Common Files\Microsoft Shared\Web Server Extensions\12\ISAPI\

    NoteNote
    If you are developing on a 64-bit server, you should manually browse to drive\Program Files instead of using the %ProgramFiles% environment variable. Because Visual Studio 2008 is a 32-bit application running under the WOW64 emulator, %ProgramFiles% resolves to drive\Program Files (x86) in a 64-bit environment.
  5. Add a reference to the ActiveIssuesHealth project to the ActiveIssuesHealthConfigproject.

After you have created the project, you can develop your Stsadm extensions.

Procedure 16. Develop the SetSspName Stsadm extension

  1. Add the following lines to the global references section of SetSspName.cs.

  2. Add the following constant to the SetSspName class.

  3. Modify the SetSspName class to implement the ISPStsadmCommand interface.

  4. Remove the throw new NotImplementedException() statement from both methods.

  5. Develop the GetHelpMessage method. This method simply prints out a help message for the extension.

  6. Create a method named SspName.

  7. Develop the SspName method. This method instantiates the necessary SharePoint classes, validates that the user is attempting to configure a PWA site, and passes the parameters specified on the command line to the HandlerSettings class that you created earlier, for storage in the SPPropertyBag.

  8. Develop the Run method. This method validates the syntax and calls the SspName method that you created earlier.

The SetActiveStatusValue class is very similar to the SetSspName class.

Procedure 17. Develop the SetActiveStatusValue Stsadm extension

  1. In Solution Explorer, add a new class named SetActiveStatusValue.cs to the ActiveIssuesHealthConfig project.

  2. Add the following lines to the global references section of SetActiveStatusValue.cs.

  3. Add the following constant to the SetActiveStatusValue class.

  4. Modify the SetActiveStatusValue class to implement the ISPStsadmCommand interface.

  5. Remove the throw new NotImplementedException() statement from both methods.

  6. Develop the GetHelpMessage method. This method simply prints out a help message for the extension.

  7. Create a method named ActiveStatusValue.

  8. Develop the ActiveStatusValue method. This method instantiates the necessary SharePoint classes, validates that the user is attempting to configure a PWA site, and passes the parameters specified on the command line to the HandlerSettings class that you created earlier, for storage in the SPPropertyBag.

  9. Develop the Run method. This method validates the syntax and calls the ActiveStatusValue method that you created earlier.

The SetTargetCustomField class is very similar to the previous two classes that you created.

Procedure 18. Develop the SetTargetCustomField Stsadm extension

  1. In Solution Explorer, add a new class named SetTargetCustomField.cs to the ActiveIssuesHealthConfig project.

  2. Add the following lines to the global references section of SetTargetCustomField.cs.

  3. Add the following constant to the SetTargetCustomField class.

  4. Modify the SetTargetCustomField class to implement the ISPStsadmCommand interface.

  5. Remove the throw new NotImplementedException() statement from both methods.

  6. Develop the GetHelpMessage method. This method simply prints out a help message for the extension.

  7. Create a method named ActiveStatusValue.

  8. Develop the TargetCustomField method. This method instantiates the necessary SharePoint classes, validates that the user is attempting to configure a PWA site, and passes the parameters specified on the command line to the HandlerSettings class that you created earlier, for storage in the SPPropertyBag.

  9. Develop the Run method. This method validates the syntax and calls the TargetCustomField method that you created earlier.

Procedure 19. Create the VSeWSS project and set references

  1. Add a new SharePoint project of type Empty named ActiveIssuesHealthWsp to the solution.

  2. In the Select Trust Level dialog box, choose Full Trust.

  3. Add a reference to the ActiveIssuesHealth project to the ActiveIssuesHealthWspproject.

  4. Add a reference to the ActiveIssuesHealthConfig project to the ActiveIssuesHealthWsp project.

With the VSeWSS project created, now create a feature and configure it.

Procedure 20. Complete the feature package

  1. Add a new SharePoint item of type Root File named StsAdmCommands.ActiveIssuesHealth.xml to the ActiveIssuesHealthWsp project.

  2. In Solution Explorer, double-click StsAdmCommands.ActiveIssuesHealth.xml to open it for editing.

  3. Delete the current contents of StsAdmCommands.ActiveIssuesHealth.xml and replace with the following.

    NoteNote
    Replace the three PublicKeyTokens above with the correct value for your assembly.
  4. Create a new folder named Config in the RootFiles folder of the ActiveIssuesHealthWsp project.

  5. In Solution Explorer, drag StsAdmCommands.ActiveIssuesHealth.xml into the Config folder that you just created.

  6. In Solution Explorer, switch to the WSP View, and then click Refresh.

  7. Click Create New Feature.

  8. When prompted, choose a Web scope. Ensure that the options to create a default manifest.xml and feature receiver are cleared (not selected).

  9. Rename the feature to ActiveIssuesHealth.

  10. Expand the feature, and then double-click feature.xml to open it for editing.

  11. Being careful to not change the Feature Id setting, make the following modifications to the file.

    NoteNote
    Replace the PublicKeyToken above with the correct value for your assembly.
  12. Save and close feature.xml.

  13. On the Build menu, click Package Solution.

Avoiding Infinite Recursion

Project Server invokes ActiveIssuesHealth through the OnPublished event, which fires after a project publish is complete. If ActiveIssuesHealth updates and republishes the project, it causes Project Server to raise the OnPublished event for a second time, and so on.

In this article, I mitigated the risk of an infinite loop by having the CreateUpdate method return a null value when it attempts to process a project with no change in the number of active issues. This null value is passed into the UpdateProject method, which terminates in response.

When developing Project Server event handlers, you must always guard against an infinite recursion loop, because this defect can bring down a SharePoint farm in seconds.

Deploying the ActiveIssuesHealth Event Handler

To deploy the ActiveIssuesHealth event handler, you can use the VSeWSSautomatic deployment functionality, deploy with a mix of Stsadm commands and Web functionality, or use Stsadm exclusively. In this procedure, I demonstrate the second (mixed) option.

Procedure 21. Deploy ActiveIssuesHealth

  1. Open a Command Prompt window and browse to the bin directory in the "12 Hive". Following is the default location:

    c:\Program Files\Common Files\Microsoft Shared\Web Server Extensions\12\BIN\

  2. Use the Stsadm addsolution operation to install the solution. For example, you would execute the following:

    stsadm -o addsolution -filename c:\ActiveIssuesHealthWsp.wsp

  3. After the command reports a successful completion, open Internet Explorer and browse to Central Administration.

  4. Open the Operations section. In the Global Configuration section, click Solution Management.

  5. On the Solution Management page, in the list of installed solutions, click ActiveIssuesHealthwsp.wsp.

  6. Click Deploy Solution.

  7. Specify a deployment time, if desired. Otherwise, ensure that the Now option is selected, and then click OK.

  8. This returns you to the Solution Management page. After the status column for ActiveIssuesHealthwsp.wsp displays Deployed, browse to the PWA site where you want to install ActiveIssuesHealth.

  9. On the Site Actions menu, click Site Settings.

  10. On the Site Settings page, in the Site Administration section, click Site features.

  11. For ActiveIssuesHealth, click Activate.

  12. Use the Stsadm extensions to configure the handler's settings, and then publish a test project.

Conclusion

For all of its features, the true power of Project Server is its vast capacity for extensibility and customization. Instead of merely providing an API with which developers can exchange data with the system, or attempting to cram in every conceivable feature, Microsoft built a solid architecture that enables third-party applications to inject their own business logic and other customizations into the platform with relative simplicity. This is true not only of Project Server, but also of Windows SharePoint Services 3.0 and SharePoint Server.

Additional Resources

For more information, see the following resources: