Planner: Cloning a Plan with multiple assignments

This post has been republished via RSS; it originally appeared at: Planner Blog articles.

First published on MSDN on Apr 17, 2017
I updated my previous posting with a rough re-write of my cloning PowerShell – but I have completed a complete re-write that handles the objects in PowerShell much better (thanks for the feedback and guidance Tarkan!).

Here is a zipped up version of the ps1 - plannerclonemultiassignv3

*** Update 6/13/2017 - the Planner Graph API is now at General Availability - simply replacing /beta/ with /v1.0/ will work in the attached example - or this new modified version has the changes- PlannerCloneMultiAssignV5 ***

I’m starting from a similar ‘template’ as before and my plan looks like this:



Some of the tasks have dates set for start and due dates – some haven’t.  In my code if I find a date I’m adding 7 days to it for the clone just to show how you could move dates – for a real template scenario you’d probably pass in a parameter.  The PowerShell ps1 is attached – and I’ll also walk through some of the code pointing out some of the more interesting bits (at least to me!)

I won’t go over the initial part again where I get the token and use the AppId – so if you are fresh to this topic you might want to read the previous blog post first - https://blogs.msdn.microsoft.com/brismith/2017/02/17/microsoft-planner-how-to-clone-a-plan-with-graph/ .  The main changes in the Graph for this update is the move to multi-assign – so not the assignedTo has changed to an assignment collection of plannerAssignments – see https://developer.microsoft.com/en-us/graph/docs/api-reference/beta/resources/plannertask for the documentation on this.  Another big change is the endpoints now all have ‘planner’ added before the entity – so for example to get tasks you now go to GET /planner/tasks/<id> – where id is the Plan Id.

The changes in the flow of my code is that I now create all the buckets and then all the tasks and then add all the task details – rather than doing the other entities within each bucket in one loop.  I’m keeping track of the old/new Ids for buckets and tasks in hash tables, and I’ve renamed my variables to hopefully make it more obvious what I’m doing.  I’m also manipulating the returned objects more in PowerShell rather than building up new objects – so removing read-only fields or ones I don’t want to set, then setting where necessary, like new Ids for buckets, and also making sure all checklists are unchecked.  I’m also updating a few fields – like the dates as mentioned above – and also setting the orderHints as appropriate (basically adding <existingHint>P!’ to the current one should do the trick, although I’m still not seeing the order quite as I expect…).  I’ll try and get a better answer on that one.  As you will notice – not much has changed with error and exception handling…

So, on with the code.  Not all of the code is listed here – but the ps1 is attached.  I’ll jump in at the point where I have authenticated, grabbed a token and already read the template details and created the Group, added the members and created the Plan – so first thing is to set the categories I’m using on the plan (this might be a useful stand-alone thing just to stamp all your plans with a consistent set of categories!)
$newCategoryDescriptions = $templatePlanDetailsContent.categoryDescriptions | ConvertTo-Json

# Do a GET so I have the current etag
$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)

$uri = " https://graph.microsoft.com/beta/planner/plans/" + $newPlanId + "/details"
$result = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers
$newPlanDetailsContent = $result.Content | ConvertFrom-Json
$Request = @"
{
"categoryDescriptions": $newCategoryDescriptions
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('If-Match', $newPlanDetailsContent.'@odata.etag')
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)
$headers.Add('Prefer', "return=representation")

$uri = " https://graph.microsoft.com/beta/planner/plans/" + $newPlanId + "/details"

$result = Invoke-WebRequest -Uri $uri -Method PATCH -Body $Request -Headers $headers

The first line is just taking my categoryDescriptions property and converting to json – which is just what I need in my $Request.  I then get the current etag that I’ll need for my header – and that’s about it.  As you can see the $uri goes to " https://graph.microsoft.com/beta/planner/plans/" + $planId + "/details" with the new ‘planner’ identifier.

Next I walk through my buckets, creating a hash table so I can use this as a lookup to see which tasks should land in which buckets later.
$bucketHashTable = @{}

ForEach($templateBucket in $templateBucketsValue){

$templateBucketId = $templateBucket.Id

$templateBucket.PSObject.Members.Remove("Id")
$templateBucket.PSObject.Members.Remove("@odata.etag")
$templateBucket.orderHint = "$($templateBucket.orderHint) $($templateBucket.orderHint)P!"
$templateBucket.planId = $newPlanId

$Request = @"
$($templateBucket | ConvertTo-Json)
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)
$headers.Add('Prefer', "return=representation")

$newBucket = Invoke-WebRequest -Uri " https://graph.microsoft.com/beta/planner/buckets" -Method Post -Body $Request -Headers $headers
$newBucketContent = $newBucket.Content | ConvertFrom-Json
$newBucketId = $newBucketContent.id

$bucketHashTable.Add($templateBucketId, $newBucketId)
}

Similar to my newCategoryDescriptions I can re-use the object I have – and am just trimming out the old Id (keeping a copy first) and the etag – then correcting the newPlanId and the orderHint – and all is good!  I put the old and new bucket Ids into my hash table.

Tasks and assignments are all in the task object – so I can add them all in one rather than using the POST then PATCH that I did in the earlier blog post.  I end up removing rather a lot of the members of this object – and it may have been easier to just build a clean one – but thought it worth doing it this way for illustration of the members involved.  Another key part here is taking a copy of the object rather than working on the object from my ForEach, as in PowerShell the objects are just references – so removing something in the loop removes from the collection.
$taskHashTable = @{}

ForEach($templateTask in $templateTasksValue){

$tempTask = $templateTask.PSObject.Copy()

$tempTask.PSObject.Members.Remove("Id")
$tempTask.PSObject.Members.Remove("@odata.etag")
$tempTask.PSObject.Members.Remove("createdDateTime")
$tempTask.PSObject.Members.Remove("createdBy")
$tempTask.PSObject.Members.Remove("conversationThreadId")
$tempTask.PSObject.Members.Remove("percentComplete")
$tempTask.PSObject.Members.Remove("hasDescription")
$tempTask.PSObject.Members.Remove("referenceCount")
$tempTask.PSObject.Members.Remove("checklistItemCount")
$tempTask.PSObject.Members.Remove("activeChecklistItemCount")
$tempTask.PSObject.Members.Remove("assigneePriority")
$tempTask.PSObject.Members.Remove("previewType")
$tempTask.PSObject.Members.Remove("completedDateTime")
$tempTask.PSObject.Members.Remove("completedBy")
If($tempTask.startDateTime){
$tempTask.startDateTime = ([DateTime]$tempTask.dueDateTime).AddDays(7)
}
If($tempTask.dueDateTime){
$tempTask.dueDateTime = ([DateTime]$tempTask.dueDateTime).AddDays(7)
}
$tempTask.orderHint = "$($tempTask.orderHint) $($tempTask.orderHint)P!"
$tempTask.planId = $newPlanId
$tempTask.bucketId = $bucketHashTable.Get_Item($templateTask.bucketId)

$assignees = $tempTask.assignments.PSObject.Properties | Select Name, Value

ForEach($assignee in $assignees){
$assignee.Value.PSObject.Members.Remove("assignedBy")
$assignee.Value.PSObject.Members.Remove("assignedDateTime")
$assignee.Value.orderHint = "$($assignee.orderHint) $($assignee.orderHint)P!"
}

$Request = @"
$($tempTask | ConvertTo-Json)
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)
$headers.Add('Prefer', "return=representation")

$newTask = Invoke-WebRequest -Uri " https://graph.microsoft.com/beta/planner/tasks" -Method Post -Body $Request -Headers $headers
$newTaskContent = $newTask.Content | ConvertFrom-Json
$newTaskId = $newTaskContent.id

$taskHashTable.Add($templateTask.Id, $newTaskId)

}

As I mentioned earlier – I’m just adding 7 days – but you could pass in a value.

Finally I set the task details –and again this is by maniulating a copy of the object from the template and handling the previewType and checklists all in one loop.  You could also add the references in here easily enough – but one consideration is where the references refer to.  If they are public web references or static internal links then all is fine – but if they refer to items stored in the Group’s SharePoint site then you’n need to consider how you want to handle this.  Possibly you’d need to copy the contents across and update the references accordingly.
ForEach($templateTaskDetailContent in $templateTaskDetailsContents){

$newTaskDetailContent = $templateTaskDetailContent.PSObject.Copy()

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
# Getting the current etag
$uri = " https://graph.microsoft.com/beta/planner/tasks/" + $taskHashTable.Get_Item($templateTaskDetailContent.Id) + "/details"

$result = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers
$freshEtagTaskContent = $result.Content | ConvertFrom-Json
$newTaskDetailContent.PSObject.Members.Remove("@odata.context")
$newTaskDetailContent.PSObject.Members.Remove("@odata.etag")
$newTaskDetailContent.PSObject.Members.Remove("id")
$newTaskDetailContent.PSObject.Members.Remove("references")

$checklist = $newTaskDetailContent.checklist.PSObject.Properties | Select Name, Value

ForEach($checkItem in $checklist){
$checkItem.Value.PSObject.Members.Remove("lastModifiedBy")
$checkItem.Value.PSObject.Members.Remove("lastModifiedDateTime")
$checkItem.Value.isChecked = "false"
$checkItem.Value.orderHint = "$($checkItem.Value.orderHint) $($checkItem.Value.orderHint)P!"
}

$Request = @"
$($newTaskDetailContent | ConvertTo-Json)
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('If-Match', $freshEtagTaskContent.'@odata.etag')
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)

$uri = " https://graph.microsoft.com/beta/planner/tasks/" + $taskHashTable.Get_Item($templateTaskDetailContent.Id) + "/details"

$result = Invoke-WebRequest -Uri $uri -Method PATCH -Body $Request -Headers $headers
}

My new clone plan looks like the following – and as mentioned I’m still trying to get the right logic for the ordering of tasks and checklist – the buckets look just fine. It might come down to sorting the items within the buckets/lists in PowerShell and then applying a new orderHint as per the documents https://developer.microsoft.com/en-us/graph/docs/api-reference/beta/resources/planner_order_hint_format .  I’m keen to get some good examples of that – as I see of StackOverflow that a few of you are struggling with orderHint.

Leave a Reply

Your email address will not be published. Required fields are marked *

*

This site uses Akismet to reduce spam. Learn how your comment data is processed.