Monday, November 25, 2024

Migrating and Preserving ModifiedOn, ModifiedBy values of Dynamics 365/CRM

Out of the box there's no solution to keep the original source values of ModifiedOn and ModifiedBy when migrating data records to Dynamics 365. In an on-premises deployment you could accomplish the task by the unsupported query update on the destination database; but if you are migrating to cloud Dynamics 365 database access is not an option. In this article I'll walk you through step-by-step guide of one solution proven to work in migration projects. The migration scenario is to move data from an older Dynamics CRM system to online Dynamics 365/CRM; but this could work for other data sources wishing to migrate to Dynamics 365/CRM.

The steps are:
1. Create temporary fields in destination entity to hold the source values of ModifiedOn and ModifiedBy.
2. Write plug-in to set ModifiedOn/By values of destination entity.
3. Register the plug-in.
4. Map source fields to temporary fields and run the migration/import

Step 1: Create 2 temp fields new_ModifiedOn and new_ModifiedBy in the destination environment. Whatever method/tool of migration you will use, the source ModifiedOn will set value for destination new_ModifiedOn; and source ModifiedBy will set value for destination new_ModifiedBy.

Step 2: Write plug-in to set value of destination ModifiedOn and ModifiedBy. Core code for the plug-in is at the end.

Step 3: Register the plug-in, on create message and Pre-Operation.

Step 4: Map the fields for migration/import, like I mentioned in Step 1 - source ModifiedOn to destination new_ModifiedOn, and ModifiedBy to new_ModifiedBy

Now if you run the migration/import, the plug-in will pickup the original values in temp fields and set them for ModifiedOn/ModifiedBy upon creation of new record.

Here is my plugin:

using System;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;

namespace TXN.Plugin.PreserveRecordInfo
{
    public class PreCreateRecord : IPlugin
    {
        #region Secure/Unsecure Configuration Setup
        private string _secureConfig = null;
        private string _unsecureConfig = null;

        public PreCreateRecord(string unsecureConfig, string secureConfig)
        {
            _secureConfig = secureConfig;
            _unsecureConfig = unsecureConfig;
        }
        #endregion
        public void Execute(IServiceProvider serviceProvider)
        {
            ITracingService tracer = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
            IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
            IOrganizationServiceFactory factory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
            IOrganizationService service = factory.CreateOrganizationService(context.UserId);

            if (context == null) throw new InvalidPluginExecutionException("IPluginExecutionContext is null");
            if (service == null) throw new InvalidPluginExecutionException("IOrganizationService is null");
            if (tracer == null) throw new InvalidPluginExecutionException("ITracingService is null");

            tracer.Trace("TXN: message: " + context.MessageName + " | Timestamp: " + DateTime.Now);
            
            if (context.InputParameters.Contains("Target") && context.InputParameters["Target"] is Entity)
            {
                Entity entity = (Entity)context.InputParameters["Target"];

                string _message = context.MessageName.ToUpper();
                string _entityLogical = entity.LogicalName;

                tracer.Trace("TXN: entity: " + entity.LogicalName + " | GUID: " + entity.Id.ToString());
                //TXN: Filter the message and your entities here
                if (!(_message == "CREATE" ) ||
                    !(_entityLogical == "knowledgearticle" || 
                      _entityLogical == "adobe_agreement" ||
                      _entityLogical == "phonecall" ||
                      _entityLogical == "fax" ||
                      _entityLogical == "letter" ||
                      _entityLogical == "appointment" ||
                      _entityLogical == "email" ||
                      _entityLogical == "task"))
                    return;

                try
                {
                    DateTime _initDate = new DateTime(2099, 12, 12);
                    DateTime _modifiedOn = _initDate;
                    string _modifiedBy = null;

                    if (entity.Contains("new_modifiedon") && entity.Attributes["new_modifiedon"] != null)
                    {
                        _modifiedOn = (DateTime)entity.Attributes["new_modifiedon"];
                        tracer.Trace("TXN: mapped new_modifiedon: " + _modifiedOn.ToString());
                    }

                    if (entity.Contains("new_modifiedbyname") && entity.Attributes["new_modifiedbyname"] != null)
                    {
                        _modifiedBy = (entity.Attributes["new_modifiedbyname"]).ToString();
                        tracer.Trace("TXN: mapped new_modifiedbyname: " + _modifiedBy);
                    }

                    //TXN: Set original modifiedon value in destination record
                    if(!_modifiedOn.Equals(_initDate))
                    {
                        entity.Attributes["modifiedon"] = _modifiedOn;
                        tracer.Trace("TXN: set modifiedon to: " + _modifiedOn.ToString());
                    }

                    //TXN: find new_modifiedbyname and set that user to destination record's modifiedby
                    if (_modifiedBy != null)
                    {
                        var fetchXml = @"<fetch version='1.0' output-format='xml-platform' mapping='logical' distinct='false'>
                              <entity name='systemuser'>
                                <attribute name='fullname' />
                                <attribute name='systemuserid' />
                                <filter type='and'>
                                  <condition attribute='fullname' operator='eq' value='{0}' />
                                </filter>
                              </entity>
                            </fetch>";
                        fetchXml = string.Format(fetchXml, _modifiedBy);
                        EntityCollection users = service.RetrieveMultiple(new FetchExpression(fetchXml));
                        if (users != null && users.Entities.Count > 0)
                        {
                            entity.Attributes["modifiedby"] = users.Entities[0].ToEntityReference();
                            tracer.Trace("TXN: set modifiedby to user: " + users.Entities[0].GetAttributeValue<string>("fullname"));
                        }
                    }
                }
                catch (Exception e)
                {
                    throw new InvalidPluginExecutionException(e.Message);
                }
            }
        }
    }
}

Friday, November 22, 2024

How to list shared Dynamics records and user access levels with Power BI

We all know about the POA table (principalobjectaccess) that holds information about what records are being shared, to what users/teams, and at what access levels. Here I will show how to report such information with Power BI.

First, a recap of important POA table columns:

  • objectid: GUID of the record being shared
  • objecttypecode: logical name of the record being shared
  • principalid: GUID of the user/team/organization the record is shared with
  • principaltypecode: has value of systemuser, team, or organization, depends on above principalid
  • accessrightsmask: mask value representing level of access rights to the shared record. 

Let's focus on a concrete use case for this report: I want to see a list of shared Cases, along with the users being shared, and what access level is being granted. The end result would look like this:

Step 1: Launch Power BI Desktop. At the time of this writing Power BI online does not support Dataverse or CDS, the 2 data sources we need for this task. Create a blank report.

Step 2: Get data for 3 tables systemuser, incident, and principalobjectaccessset. The first 2 tables you will get data from Power Platform | Dataverse. The last table POA you will have to get it from Common Data Service (Legacy) source

Step 3: Establish relationships between tables. Switch to Model view. 

Create Many-to-one relationship from principleobjectaccessset/objectid to incident/Case(incidentid).

Create second Many-to-one relationship from principleobjectaccessset/principalid to systemuser/User(systemuserid)


Step 4: Create the report. Switch to Report view and choose the columns from Data tab for the report. On Filters tab, filter column objecttypecode to incident, filter column principaltypecode to systemuser

Now you have the report. My sample includes 2 Case records being shared with few users with different access levels (1 - read; 3 - read, write; 7 - read, write, append)