diff --git a/CHANGELOG.md b/CHANGELOG.md index 681fd03e5..ae76de89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Current nightly] ### Added +- Added `Get-PnPUserAndContentMoveState` cmdlet to retrieve SharePoint Online user and OneDrive content move states. - Added `Get-PnPMultiGeoCompanyAllowedDataLocation` cmdlet to retrieve SharePoint Online multi-geo allowed data locations. [#5336](https://github.com/pnp/powershell/pull/5336) - Added `Get-PnPGeoMoveCrossCompatibilityStatus` cmdlet to retrieve SharePoint Online multi-geo move compatibility statuses. diff --git a/documentation/Get-PnPUserAndContentMoveState.md b/documentation/Get-PnPUserAndContentMoveState.md new file mode 100644 index 000000000..0a1e5f859 --- /dev/null +++ b/documentation/Get-PnPUserAndContentMoveState.md @@ -0,0 +1,193 @@ +--- +Module Name: PnP.PowerShell +title: Get-PnPUserAndContentMoveState +schema: 2.0.0 +applicable: SharePoint Online +external help file: PnP.PowerShell.dll-Help.xml +online version: https://pnp.github.io/powershell/cmdlets/Get-PnPUserAndContentMoveState.html +--- + +# Get-PnPUserAndContentMoveState + +## SYNOPSIS +Returns the state of SharePoint Online user and OneDrive content move jobs. + +## SYNTAX + +### MoveReport + +```powershell +Get-PnPUserAndContentMoveState [-Limit ] [-MoveStartTime ] [-MoveEndTime ] [-MoveState ] [-MoveDirection ] [-Connection ] +``` + +### UserPrincipalName + +```powershell +Get-PnPUserAndContentMoveState -UserPrincipalName [-Connection ] +``` + +### OdbMoveId + +```powershell +Get-PnPUserAndContentMoveState -OdbMoveId [-Connection ] +``` + +## DESCRIPTION +Returns status information for SharePoint Online multi-geo user and OneDrive content move jobs. You can retrieve one move job by user principal name or OneDrive move ID, or retrieve a move report filtered by state, direction, time window, and limit. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +Get-PnPUserAndContentMoveState -UserPrincipalName user@contoso.com +``` + +Returns the move state for the specified user. + +### EXAMPLE 2 + +```powershell +Get-PnPUserAndContentMoveState -OdbMoveId 8f6f8e3a-2c1f-4d5b-9a7e-6b3c2a1f0e9d +``` + +Returns the move state for the specified OneDrive move job ID. + +### EXAMPLE 3 + +```powershell +Get-PnPUserAndContentMoveState -MoveState All -MoveDirection All -Limit 100 +``` + +Returns up to 100 user and content move jobs regardless of state or direction. + +### EXAMPLE 4 + +```powershell +Get-PnPUserAndContentMoveState -MoveStartTime (Get-Date).AddDays(-7) -MoveEndTime (Get-Date) -MoveState Failed -MoveDirection MoveOut -Verbose +``` + +Returns failed move-out jobs from the last seven days and includes additional diagnostic properties. + +## PARAMETERS + +### -UserPrincipalName +The user principal name of the user whose move state should be retrieved. + +```yaml +Type: String +Parameter Sets: UserPrincipalName + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -OdbMoveId +The OneDrive move job ID whose state should be retrieved. + +```yaml +Type: Guid +Parameter Sets: OdbMoveId + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Limit +Limits the number of move report entries returned. The value must be between 1 and 1000. + +```yaml +Type: UInt32 +Parameter Sets: MoveReport + +Required: False +Position: Named +Default value: 0 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -MoveStartTime +Filters move report entries to moves starting at or after the specified date and time. The value is converted to UTC before it is sent to SharePoint Online. + +```yaml +Type: DateTime +Parameter Sets: MoveReport + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -MoveEndTime +Filters move report entries to moves ending at or before the specified date and time. The value is converted to UTC before it is sent to SharePoint Online. + +```yaml +Type: DateTime +Parameter Sets: MoveReport + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -MoveState +Filters move report entries by move state. Valid values are `NotStarted`, `InProgress`, `Success`, `Failed`, `Stopped`, `Queued`, `NotSupported`, `Rescheduled`, and `All`. + +```yaml +Type: MoveState +Parameter Sets: MoveReport + +Required: False +Position: Named +Default value: NotStarted +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -MoveDirection +Filters move report entries by move direction. Valid values are `MoveOut`, `MoveIn`, and `All`. + +```yaml +Type: MoveDirection +Parameter Sets: MoveReport + +Required: False +Position: Named +Default value: MoveOut +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Connection +Optional connection to be used by the cmdlet. Retrieve the value for this parameter by specifying `-ReturnConnection` on `Connect-PnPOnline` or by executing `Get-PnPConnection`. + +```yaml +Type: PnPConnection +Parameter Sets: (All) + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +## OUTPUTS + +### System.Management.Automation.PSObject +Returns objects with `UserPrincipalName`, `MoveJobId`, `SourceDataLocation`, `DestinationDataLocation`, `TimeStamp`, and `MoveState` properties. Validation-only move jobs return `ValidationState` instead of `TimeStamp` and `MoveState`. When `-Verbose` is specified, additional move job details are returned. + +## RELATED LINKS + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) \ No newline at end of file diff --git a/src/Commands/Admin/GetUserAndContentMoveState.cs b/src/Commands/Admin/GetUserAndContentMoveState.cs new file mode 100644 index 000000000..924d822c2 --- /dev/null +++ b/src/Commands/Admin/GetUserAndContentMoveState.cs @@ -0,0 +1,163 @@ +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Model; +using PnP.PowerShell.Commands.Utilities.MultiGeo; +using System; +using System.Globalization; +using System.Linq; +using System.Management.Automation; + +namespace PnP.PowerShell.Commands.Admin +{ + [Cmdlet(VerbsCommon.Get, "PnPUserAndContentMoveState", DefaultParameterSetName = ParameterSetMoveReport)] + [RequiredApiApplicationPermissions("sharepoint/Sites.FullControl.All")] + [RequiredApiDelegatedPermissions("sharepoint/AllSites.FullControl")] + [OutputType(typeof(PSObject))] + public class GetUserAndContentMoveState : PnPSharePointOnlineAdminCmdlet + { + private const string ParameterSetMoveReport = "MoveReport"; + private const string ParameterSetUserPrincipalName = "UserPrincipalName"; + private const string ParameterSetOdbMoveId = "OdbMoveId"; + private static readonly DateTime MinSpecificDate = new(1900, 1, 1); + private static readonly DateTime MaxSpecificDate = new(9000, 1, 1); + + [Parameter(Mandatory = true, ParameterSetName = ParameterSetUserPrincipalName)] + [ValidateNotNullOrEmpty] + public string UserPrincipalName { get; set; } + + [Parameter(Mandatory = true, ParameterSetName = ParameterSetOdbMoveId)] + [ValidateNotNullOrEmpty] + public Guid OdbMoveId { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = ParameterSetMoveReport)] + [ValidateRange(1, 1000)] + public uint Limit { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = ParameterSetMoveReport)] + public DateTime MoveStartTime { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = ParameterSetMoveReport)] + public DateTime MoveEndTime { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = ParameterSetMoveReport)] + public MoveState MoveState { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = ParameterSetMoveReport)] + public MoveDirection MoveDirection { get; set; } + + protected override void ExecuteCmdlet() + { + var multiGeoRestApiClient = new MultiGeoRestApiClient(AdminContext); + var includeVerboseProperties = IsVerboseMode(); + + if (ParameterSetName == ParameterSetUserPrincipalName) + { + WriteMoveState(multiGeoRestApiClient.GetUserAndContentMoveState(UserPrincipalName), includeVerboseProperties); + return; + } + + if (ParameterSetName == ParameterSetOdbMoveId) + { + WriteMoveState(multiGeoRestApiClient.GetUserAndContentMoveState(OdbMoveId), includeVerboseProperties); + return; + } + + var moveStartTimeInUtc = MoveStartTime == DateTime.MinValue ? DateTime.MinValue : MoveStartTime.ToUniversalTime(); + var moveEndTimeInUtc = MoveEndTime == DateTime.MinValue ? DateTime.MinValue : MoveEndTime.ToUniversalTime(); + var moveStates = multiGeoRestApiClient.GetUserAndContentMoveStates(MoveState, MoveDirection, moveStartTimeInUtc, moveEndTimeInUtc, Limit) + .Where(moveState => moveState != null) + .OrderByDescending(moveState => moveState.LastModified) + .Select(moveState => ConvertToPSObject(moveState, includeVerboseProperties)); + + WriteObject(moveStates, true); + } + + private void WriteMoveState(UserAndContentMoveState moveState, bool includeVerboseProperties) + { + if (moveState != null) + { + WriteObject(ConvertToPSObject(moveState, includeVerboseProperties)); + } + } + + private bool IsVerboseMode() + { + return MyInvocation.BoundParameters.TryGetValue("Verbose", out var verboseValue) && verboseValue is SwitchParameter verbose && verbose.ToBool(); + } + + private static PSObject ConvertToPSObject(UserAndContentMoveState moveState, bool includeVerboseProperties) + { + var result = new PSObject(); + AddProperty(result, "UserPrincipalName", moveState.UserPrincipalName); + AddProperty(result, "MoveJobId", moveState.Id); + AddProperty(result, "SourceDataLocation", moveState.SourceDataLocation); + AddProperty(result, "DestinationDataLocation", moveState.DestinationDataLocation); + + if (moveState.Option.HasFlag(MoveOption.ValidationOnly)) + { + AddProperty(result, "ValidationState", "Success"); + } + else + { + AddProperty(result, "TimeStamp", moveState.LastModified.ToLocalTime()); + AddProperty(result, "MoveState", GetMoveStateDisplayValue(moveState)); + } + + if (includeVerboseProperties) + { + AddVerboseProperties(result, moveState); + } + + return result; + } + + private static void AddVerboseProperties(PSObject result, UserAndContentMoveState moveState) + { + AddProperty(result, "IsValidPDL", moveState.ValidationResult == PreferredDataLocationValidationResult.Valid); + AddProperty(result, "HasODBInCurrentLocation", moveState.HasOdbInSourceDataLocation); + + if (moveState.State == MoveState.Success) + { + AddProperty(result, "IsContentMoved", moveState.IsContentMoved); + } + + AddProperty(result, "ErrorMessage", moveState.ErrorMessage); + AddProperty(result, "SiteId", moveState.SiteId); + AddProperty(result, "MoveDirection", moveState.Direction); + AddProperty(result, "MoveJobPhase", moveState.JobPhase); + AddProperty(result, "MoveJobType", moveState.Type); + AddProperty(result, "PreferredMoveBeginDate", FormatSpecificDate(moveState.PreferredMoveBeginDateInUtc)); + AddProperty(result, "PreferredMoveEndDate", FormatSpecificDate(moveState.PreferredMoveEndDateInUtc)); + AddProperty(result, "StartedDate", FormatSpecificDate(moveState.StartedDateInUtc)); + AddProperty(result, "FinishedDate", FormatSpecificDate(moveState.FinishedDateInUtc)); + AddProperty(result, "Option", moveState.Option); + AddProperty(result, "TriggeredBy", moveState.TriggeredBy); + } + + private static string GetMoveStateDisplayValue(UserAndContentMoveState moveState) + { + if (!string.IsNullOrWhiteSpace(moveState.StateName)) + { + return moveState.StateName; + } + + return moveState.State switch + { + MoveState.NotStarted => "ReadyToTrigger", + MoveState.Queued => "Scheduled", + MoveState.InProgress => string.Format(CultureInfo.InvariantCulture, "{0}({1}/4)", moveState.State, (int)moveState.JobPhase), + _ => moveState.State.ToString() + }; + } + + private static DateTime? FormatSpecificDate(DateTime dateTime) + { + return dateTime > MinSpecificDate && dateTime < MaxSpecificDate ? dateTime.ToLocalTime() : null; + } + + private static void AddProperty(PSObject result, string name, object value) + { + result.Properties.Add(new PSNoteProperty(name, value)); + } + } +} \ No newline at end of file diff --git a/src/Commands/Model/UserAndContentMoveState.cs b/src/Commands/Model/UserAndContentMoveState.cs new file mode 100644 index 000000000..f524c3886 --- /dev/null +++ b/src/Commands/Model/UserAndContentMoveState.cs @@ -0,0 +1,137 @@ +using System; +using System.Text.Json.Serialization; + +namespace PnP.PowerShell.Commands.Model +{ + /// + /// Contains the state of a SharePoint Online user and OneDrive content move job. + /// + public class UserAndContentMoveState + { + public string ApiVersion { get; set; } + + public Guid Id { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public MoveOption Option { get; set; } + + public string Reserve { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public JobSubType SubType { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public JobType Type { get; set; } + + public Guid BatchId { get; set; } + + public string CancelTriggeredBy { get; set; } + + public string DestinationDataLocation { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public MoveDirection Direction { get; set; } + + public string ErrorMessage { get; set; } + + public DateTime FinishedDateInUtc { get; set; } + + public bool IsReadOnlyAlertRaised { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public MoveJobPhase JobPhase { get; set; } + + public string Notify { get; set; } + + public DateTime PreferredMoveBeginDateInUtc { get; set; } + + public DateTime PreferredMoveEndDateInUtc { get; set; } + + public Guid SiteId { get; set; } + + public string SourceDataLocation { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public MoveState State { get; set; } + + public string TriggeredBy { get; set; } + + public bool HasOdbInSourceDataLocation { get; set; } + + public string UserPrincipalName { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public PreferredDataLocationValidationResult ValidationResult { get; set; } + + public bool IsContentMoved { get; set; } + + public DateTime LastModified { get; set; } + + public DateTime StartedDateInUtc { get; set; } + + public string StateName { get; set; } + } + + public enum MoveState + { + All = -1, + NotStarted = 0, + InProgress = 1, + Success = 2, + Failed = 3, + Stopped = 4, + Queued = 5, + NotSupported = 6, + Rescheduled = 8 + } + + public enum MoveDirection + { + MoveOut = 0, + MoveIn = 1, + All = 2 + } + + public enum PreferredDataLocationValidationResult + { + Invalid = 0, + Valid = 1 + } + + public enum MoveJobPhase + { + InitialStage = 0, + SourceStage = 1, + TargetStage = 2, + PostMoveStage = 3, + FinalStage = 255 + } + + [Flags] + public enum MoveOption + { + None = 0, + OverwriteOdb = 1, + ValidationOnly = 2, + SuppressMarketplaceAppCheck = 8, + SuppressWorkflow2013Check = 16, + ContinueFromConfirmation = 32, + ValidationOnlySource = 64, + SuppressBcsCheck = 128, + EnableGLSSupportForXGeoMove = 256, + Force = int.MinValue + } + + public enum JobType + { + UserMove = 0, + GroupMove = 1, + SiteMove = 2 + } + + public enum JobSubType + { + None = 0, + CSPSiteMove = 1 + } +} \ No newline at end of file diff --git a/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs b/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs index bc75dfe12..ac79138df 100644 --- a/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs +++ b/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs @@ -3,6 +3,7 @@ using PnP.PowerShell.Commands.Model; using System; using System.Collections.Generic; +using System.Globalization; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -27,6 +28,12 @@ internal class MultiGeoRestApiClient private const string GeoMoveCompatibilityChecksPath = "GeoMoveCompatibilityChecks"; private const string AllowedDataLocationsApiVersion = "1.3.11"; private const string AllowedDataLocationsPath = "AllowedDataLocations"; + private const string UserMoveJobsApiVersion = "1.0"; + private const string UserMoveJobsByMoveIdApiVersion = "1.2.2"; + private const string UserMoveJobsReportApiVersion = "1.3.2"; + private const string UserMoveJobPathByUpn = "UserMoveJobs(upn='{0}')"; + private const string UserMoveJobPathByMoveId = "UserMoveJobs/GetByMoveId(odbMoveId='{0}')"; + private const string UserMoveJobsPathForMoveReport = "UserMoveJobs/GetMoveReport(moveState={0},moveDirection={1},startTime='{2:u}',endTime='{3:u}',limit='{4}')"; private const int MaximumPagination = 10; private static readonly TimeSpan CreateTenantRenameJobTimeout = TimeSpan.FromSeconds(300); private static readonly JsonSerializerOptions SerializerOptions = new() @@ -73,6 +80,24 @@ internal IEnumerable GetAllowedDataLocations return GetFeed(AllowedDataLocationsPath, AllowedDataLocationsApiVersion); } + internal UserAndContentMoveState GetUserAndContentMoveState(string userPrincipalName) + { + var path = string.Format(CultureInfo.InvariantCulture, UserMoveJobPathByUpn, ProcessSpecialChars(userPrincipalName)); + return Get(path, UserMoveJobsApiVersion); + } + + internal UserAndContentMoveState GetUserAndContentMoveState(Guid odbMoveId) + { + var path = string.Format(CultureInfo.InvariantCulture, UserMoveJobPathByMoveId, odbMoveId); + return Get(path, UserMoveJobsByMoveIdApiVersion); + } + + internal IEnumerable GetUserAndContentMoveStates(MoveState moveState, MoveDirection moveDirection, DateTime startTime, DateTime endTime, uint limit) + { + var path = string.Format(CultureInfo.InvariantCulture, UserMoveJobsPathForMoveReport, (int)moveState, (int)moveDirection, startTime, endTime, limit); + return GetFeed(path, UserMoveJobsReportApiVersion); + } + internal void CancelTenantRenameJob() { Post(TenantRenameJobsPathToCancelAJob, payload: null, apiVersion: TenantRenameCancelApiVersion); @@ -281,6 +306,11 @@ private static string GetStringProperty(JsonElement element, params string[] pro return null; } + private static string ProcessSpecialChars(string value) + { + return WebUtility.UrlEncode(value.Replace("/", "#", StringComparison.Ordinal).Replace("'", "|", StringComparison.Ordinal))?.Replace("+", "%20", StringComparison.Ordinal); + } + private static JsonElement UnwrapODataResponse(JsonElement responseElement) { if (responseElement.ValueKind != JsonValueKind.Object)