Entra / Microsoft 365 ยท Groups
Report plans
Generate a report of Planner tasks linked to Microsoft 365 Groups, including task counts, bucket breakdown, and member completion analysis.
Connect & set up
Run these once per session. All scopes are read-only unless the script makes changes.
Connect-MgGraph -ClientId $AppId -TenantId $TenantId -CertificateThumbprint $CertThumbprint -NoWelcome
Run it
The main script. Copy it, or download the .ps1 and run it from your console.
param([string] $TenantId = "xxxxx3f-14fc-43a2-9a7a-d2e27f4f3478",[string] $AppId = "xxxxx-026b-4c29-ab81-fa1264139c9c")function Generate-IndividualStatistics {param ([parameter(Mandatory = $true)]$ActiveTasks)# function to take a set of active tasks and figure out how well each group member is doing$IndividualStats = [System.Collections.Generic.List[Object]]::new()ForEach ($Member in $GroupMembers) {[array]$Global:MemberTasks = $ActiveTasks | Where-Object {$_.Assignee -eq $Member.displayname}[array]$InProgressTasks = $MemberTasks | Where-Object {$_."Task Status" -eq "In progress"}[array]$NotStartedTasks = $MemberTasks | Where-Object {$_."Task Status" -eq "Not started"}$AvgDays = ($MemberTasks.DaysOld |Measure-Object -Average).average$DataLine = [PSCustomObject][Ordered]@{DisplayName = $Member.displayNameTasks = $MemberTasks.count"Not started" = $NotStartedTasks.count"In progress" = $InProgressTasks.count"Average days old" = ("{0:N2}" -f $AvgDays) }$IndividualStats.Add($DataLine)}Return $IndividualStats}function Process-Tasks {param ([parameter(Mandatory = $true)]$UncompletedTasks)# Return a set of uncompleted tasks for a plan so that we can analyze who needs to do more to close# their tasks!Write-Host ("Analyzing assignments for {0} uncompleted tasks" -f $UncompletedTasks.count)$Assignments = [System.Collections.Generic.List[Object]]::new()ForEach ($Task in $UncompletedTasks) {# Write-Host ("Processing task {0}" -f $Task.title)$TaskData = @{}# Convert assignment data to a hash table for processing($Task.assignments).psObject.Properties | ForEach-Object { $TaskData[$_.Name] = $_.Value}[array]$TaskAssignments = $TaskData.Keys[array]$TaskAssignmentDates = $TaskData.Values.assignedDateTime[int]$i = 0; $DaysSinceAssignment = $NullForEach ($Assignment in $TaskAssignments) {$Assignee = $GroupMembers | Where-Object {$_.Id -eq $Assignment} | Select-Object -ExpandProperty displayNameIf ($Assignee) {$AssignedDate = ($TaskAssignmentDates[$i])$DaysSinceAssignment = (New-TimeSpan $AssignedDate).Days} Else {$Assignee = "Unassigned"$AssignedDate = $Null}$i++; $Status = $Null; $Priority = $Null; $DaysTaskOldSwitch ($Task.percentComplete) {"0" { $Status = "Not started" }"50" { $Status = "In progress"}"100" { $Status = "Complete" }}Switch ($Task.Priority) {"1" { $Priority = "Urgent" }"3" { $Priority = "Important" }"5" { $Priority = "Medium" }"9" { $Priority = "Low" }}If ($Task.createdDateTime) {$DaysTaskOld = (New-TimeSpan $Task.createdDateTime).days}If ($Task.dueDateTime) {$TaskDueDate = Get-Date($Task.dueDateTime) -format g}if ($AssignedDate) {$AssignedDate = Get-Date($AssignedDate) -format g}$DataLine = [PSCustomObject][Ordered]@{Plan = $Task.planIdPlanTitle = $PlanTitleTaskId = $Task.idTitle = $Task.titleBucket = ($BucketsTable[$Task.bucketId])Created = Get-Date ($Task.createdDateTime) -format gDueDate = $TaskDueDatepercentComplete = $Task.percentCompleteDaysOld = $DaysTaskOld"Task Status" = $StatusPriority = $PriorityAssignee = $AssigneeAssignedDate = ($AssignedDate)DaysSinceAssignment = $DaysSinceAssignment}$Assignments.Add($DataLine)} #EndForeach Assignment} #End Foreach TasksReturn $Assignments}function Get-GraphData {# Based on https://danielchronlund.com/2018/11/19/fetch-data-from-microsoft-graph-with-powershell-paging-support/# GET data from Microsoft Graph.param ([parameter(Mandatory = $true)]$AccessToken,[parameter(Mandatory = $true)]$Uri)# Check if authentication was successful.if ($AccessToken) {$Headers = @{'Content-Type' = "application\json"'Authorization' = "Bearer $AccessToken"'ConsistencyLevel' = "eventual" }# Create an empty array to store the result.$QueryResults = @()# Invoke REST method and fetch data until there are no pages left.do {$Results = ""$StatusCode = ""do {try {$Results = Invoke-RestMethod -Headers $Headers -Uri $Uri -UseBasicParsing -Method "GET" -ContentType "application/json"$StatusCode = $Results.StatusCode} catch {$StatusCode = $_.Exception.Response.StatusCode.value__if ($StatusCode -eq 429) {Write-Warning "Got throttled by Microsoft. Sleeping for 45 seconds..."Start-Sleep -Seconds 45}else {Write-Error $_.Exception}}} while ($StatusCode -eq 429)if ($Results.value) {$QueryResults += $Results.value}else {$QueryResults += $Results}$uri = $Results.'@odata.nextlink'} until (!($uri))# Return the result.$QueryResults}else {Write-Error "No Access Token"}}function Get-AccessToken {# function to return an Oauth access token# Define the values applicable for the application used to connect to the Graph$AppSecret = "szM8Q~dfpy9VvLqWGJW8Wr1SPdVby6TpWPryxb5M"# Construct URI and body needed for authentication$Uri = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"$body = @{client_id = $AppIdscope = "https://graph.microsoft.com/.default"client_secret = $AppSecretgrant_type = "client_credentials"}# Get OAuth 2.0 Token$TokenRequest = Invoke-WebRequest -Method Post -Uri $uri -ContentType "application/x-www-form-urlencoded" -Body $body -UseBasicParsing# Unpack Access Token$Global:Token = ($tokenRequest.Content | ConvertFrom-Json).access_tokenWrite-Host ("Retrieved new access token at {0}" -f (Get-Date)) -foregroundcolor redReturn $Token}# Start Processing$Version = "2.0"$HtmlReportFile = "c:\temp\GroupsPlans.html"$CSVReportFile = "c:\temp\GroupPlans.CSV"# Get access token (hopefully with the correct permissions...)$Token = Get-AccessToken# Fetch organization information$Uri = "https://graph.microsoft.com/v1.0/organization"[array]$OrgData = Get-GraphData -Uri $Uri -AccessToken $Token# Get the Microsoft 365 groups in the tenant$Uri = "https://graph.microsoft.com/v1.0/groups?`$filter=groupTypes/any(a:a eq 'unified')"[array]$Groups = Get-GraphData -AccessToken $Token -Uri $uriIf (!($Groups)) { Write-Host "Can't find any groups, so there's no plans to find either..."; break }$Groups = $Groups | Sort-Object displayNameWrite-Host ("Processing {0} groups" -f $Groups.count)# Check each group for plans and process those plans$Report = [System.Collections.Generic.List[Object]]::new()ForEach ($Group in $Groups) {$Uri = ("https://graph.microsoft.com/v1.0/groups/{0}/planner/plans" -f $Group.Id)[array]$Plans = Get-GraphData -Uri $Uri -AccessToken $TokenIf ($Plans.container) {Write-Host ("{0} plans found in group {1}" -f $Plans.count, $Group.displayName)ForEach ($Plan in $Plans) {$Global:PlanTitle = $Plan.titleWrite-Host ("Processing plan {0}" -f $PlanTitle)$FirstTask = $NUll; $NewestTask = $Null; [int]$TaskCount = 0; [array]$LowTasks = $Null; [array]$MediumTasks = $Null; [array]$UrgentTasks = $Null[array]$ImportantTasks = $Null; [array]$NotStartedTasks = $Null; [array]$InProgressTasks = $Null; [array]$CompletedTasks = $Null[array]$TaskAssignments = $Null; [array]$UncompletedTasks = $Null$DaysSinceTaskCreated = "N/A"# Get group members so that we can track assignments$Uri = ("https://graph.microsoft.com/v1.0/groups/{0}/members" -f $Group.Id)[array]$Global:GroupMembers = Get-GraphData -Uri $Uri -AccessToken $Token | Select-Object Id, displayName$Uri = ("https://graph.microsoft.com/v1.0/planner/plans/{0}/tasks" -f $Plan.id)[array]$Tasks = Get-GraphData -Uri $Uri -AccessToken $TokenIf ($Tasks.value) {Write-Host ("Found {0} tasks in plan {1}" -f $Tasks.count, $Plan.title)$FirstTask = (Get-Date($Tasks.createdDateTime[($Tasks.count-1)]) -format g)$NewestTask = Get-Date($Tasks.createdDateTime[0]) -format g# How many days since a task was created in this plan?$DaysSinceTaskCreated = (New-TimeSpan $NewestTask).Days[int]$TaskCount = $Tasks.count# Process each task to find assignment data[array]$UrgentTasks = $Tasks | Where-Object {$_.Priority -eq 1}[array]$ImportantTasks = $Tasks | Where-Object {$_.Priority -eq 3}[array]$MediumTasks = $Tasks | Where-Object {$_.Priority -eq 5}[array]$LowTasks = $Tasks | Where-Object {$_.Priority -eq 9}[array]$NotStartedTasks = $Tasks | Where-Object {$_.percentComplete -eq 0}[array]$InProgressTasks = $Tasks | Where-Object {$_.percentComplete -eq 50}[array]$CompletedTasks = $Tasks | Where-Object {$_.percentComplete -eq 100}# Get bucket data$Uri = ("https://graph.microsoft.com/v1.0/planner/plans/{0}/buckets" -f $Plan.id)[array]$Buckets = Get-GraphData -Uri $Uri -AccessToken $Token$BucketStats = [System.Collections.Generic.List[Object]]::new()ForEach ($Bucket in $Buckets) {[array]$BucketTasks = $Tasks | Where-Object {$_.bucketId -eq $Bucket.id}[array]$BucketComplete = $Tasks | Where-Object {$_.percentComplete -eq 100 -and $_.bucketId -eq $Bucket.id}[int]$ActiveBucketTasks = ($BucketTasks.count - $BucketComplete.count)If ($ActiveBucketTasks -gt 0) {$PercentActiveTasks = ($ActiveBucketTasks/$BucketTasks.count).toString("P")} Else {$PercentActiveTasks = "N/A" }$DataLine = [PSCustomObject][Ordered]@{Bucket = $Bucket.nameTasks = $BucketTasks.countComplete = $BucketComplete.countActive = $ActiveBucketTasks"% Active" = $PercentActiveTasksPlan = $Plan.titlePlanId = $Plan.Id}$BucketStats.Add($DataLine)}$Global:BucketsTable = @{}ForEach ($Bucket in $Buckets) { $BucketsTable.Add([string]$Bucket.id,[string]$Bucket.name) }# Get assignments for all uncompleted tasks[array]$UncompletedTasks = $InProgressTasks + $NotStartedTasksIf ($UncompletedTasks.count -gt 0) {[array]$TaskAssignments = Process-Tasks -UncompletedTasks $UncompletedTasks# Make sure that we have plan data in all records$TaskAssignments = $TaskAssignments | Where-Object {$_.Plan -ne $Null}}}$Buckets = $Buckets | Sort-Object Name# Generate report line for the plan$ReportLine = [PSCustomObject][Ordered]@{Plan = $Plan.titleCreated = Get-Date($plan.createddatetime) -format gTasks = $Taskcount"Oldest task" = $FirstTask"Newest task" = $NewestTask"Days since task" = $DaysSinceTaskCreated"Urgent tasks" = $UrgentTasks.count"Important tasks" = $ImportantTasks.count"Medium tasks" = $MediumTasks.count"Low tasks" = $LowTasks.count"Completed tasks" = $CompletedTasks.count"In progress tasks" = $InProgressTasks.count"Not started tasks" = $NotStartedTasks.countBuckets = ($Buckets.name -join ", ")PlanId = $Plan.IdGroup = $Group.displayNameGroupId = $Group.IdTaskStats = $TaskAssignmentsBucketStats = $BucketStatsGroupMembers = $GroupMembers }$Report.Add($ReportLine)} # End Foreach Plan} # End if}# Find the set of Microsoft 365 groups with plans$GroupsWithPlans = $Report | Select-Object Group, GroupId | Sort-Object GroupId -Unique | Sort-Object Group$CountOfPlans = ($Report.PlanId | Sort-Object -Unique).count$CountOfTasks = ($Report.Tasks | Measure-Object -Sum).sum$CountOfCompletedTasks = ($Report."Completed Tasks" | Measure-Object -Sum).sum$CountOfActiveTasks = $CountOfTasks - $CountOfCompletedTasks$PercentCompletedTasks = ($CountOfCompletedTasks/$CountOfTasks).toString("P")$PercentActiveTasks = ($CountOfActiveTasks/$CountOfTasks).toString("P")Write-Host "Generating analysis..."# Generate the report files$HtmlHeading ="<html><style>BODY{font-family: Arial; font-size: 8pt;}H1{font-size: 22px; font-family: 'Segoe UI Light','Segoe UI','Lucida Grande',Verdana,Arial,Helvetica,sans-serif;}H2{font-size: 18px; font-family: 'Segoe UI Light','Segoe UI','Lucida Grande',Verdana,Arial,Helvetica,sans-serif;}H3{font-size: 16px; font-family: 'Segoe UI Light','Segoe UI','Lucida Grande',Verdana,Arial,Helvetica,sans-serif;}TABLE{border: 1px solid black; border-collapse: collapse; font-size: 8pt;}TH{border: 1px solid #969595; background: #dddddd; padding: 5px; color: #000000;}TD{border: 1px solid #969595; padding: 5px; }td.pass{background: #B7EB83;}td.warn{background: #FFF275;}td.fail{background: #FF2626; color: #ffffff;}td.info{background: #85D4FF;}</style><body><div align=center><p><h1>Microsoft 365 Groups and Plans Report</h1></p><p><h3>Generated: " + (Get-Date -format 'dd-MMM-yyyy hh:mm tt') + "</h3></p></div>"$HtmlReport = $HtmlHeadingForEach ($G in $GroupsWithPlans) {# Report the basic statistics for the plan and bucket statistics if available$HtmlHeadingSection = ("<p><h2>Plans for Group <b><u>{0}</h2></b></u></p>" -f $G.Group)# Get the group members of the plan so that we can report individual assignments. Because a group can host multiple plans, we select the first record[array]$Global:GroupMembers = $Report | Where-Object {$_.GroupId -eq $G.GroupId} | Select-Object -First 1 |Select-Object -ExpandProperty GroupMembers# Extract Plans$GroupPlans = $Report | Where-Object {$_.GroupId -eq $G.GroupId} | Select-Object Plan, Created, Tasks, "Oldest Task", "Newest Task", "Days Since Task", "Urgent Tasks", "Important Tasks", "Medium Tasks", "Low Tasks", "Completed Tasks", "In progress Tasks", "Not started Tasks", Buckets, PlanId# Extract Bucket data for plan$GroupBuckets = $Report | Where-Object {$_.GroupId -eq $G.GroupId} | Select-Object -ExpandProperty BucketStats | Sort-Object Bucket# Extract assignments for uncompleted tasks[array]$GroupAssignments = $Report | Where-Object {$_.GroupId -eq $G.GroupId} | Select-Object -ExpandProperty TaskStats | Sort-Object Assignee$HtmlReport = "<p>" + $HtmlReport + "<p>" + $HtmlHeadingSectionForEach ($P in $GroupPlans) {# Add the basic statistics for the plan$IndividualStats = $Null$HtmlData = $P | ConvertTo-Html -Fragment$HtmlPlanHeading = ("<p><h3>Plan name: {0}</h3><p>" -f $P.Plan)# If it has any tasks, report the bucketsIf ($P.Tasks -gt 0) {$HtmlData2 = $GroupBuckets | Where-Object {$_.PlanId -eq $P.PlanId} | Sort-Object Bucket -Unique | ConvertTo-Html -Fragment$HtmlHeadingBuckets = ("<p><h3>Bucket Analysis for the <u>{0}</u> plan</h3></p>" -f $P.Plan)$HtmlReport = $HtmlReport + "<p>" + $HtmlPlanHeading + $HtmlData + $HtmlHeadingBuckets + $HtmlData2 + "<h4></h5><p><p>"} Else {$HtmlReport = $HtmlReport + "<p>" + $HtmlPlanHeading + $HtmlData + "<p>"}If ($P.Tasks -gt $P.'Completed Tasks' -and $GroupAssignments) { # We have some uncompleted tasks to report for assigned members[array]$Global:ActiveTasks = $GroupAssignments | Where-Object {$_.Plan -eq $P.PlanId} | Select-Object PlanTitle, Title, Assignee, Bucket, StartDate, DueDate, AssignedDate, "Task Status", Priority, DaysOld, DaysSinceAssignmentIf ($ActiveTasks) {$HtmlData3 = $ActiveTasks | ConvertTo-html -Fragment$HtmlHeadingAssignments = ("<p><h3>Incomplete Tasks for the <u>{0}</u> plan</h3></p>" -f $P.Plan)$HtmlReport = $HtmlReport + "<p>" + $HtmlHeadingAssignments + $HtmlData3 + "<h4></h5><p><p>"$IndividualStats = Generate-IndividualStatistics -ActiveTasks $ActiveTasks$HtmlData4 = $IndividualStats | ConvertTo-html -Fragment$HtmlHeadingIndividualStats = ("<p><h3>Indivdual Member Statistics for Incomplete Tasks for the <u>{0}</u> plan</h3></p>" -f $P.Plan)$HtmlReport = $HtmlReport + "<p>" + $HtmlHeadingIndividualStats + $HtmlData4 + "<h4></h5><p><p>"}}}} #End reporting plans for the groups# Create the HTML report$Htmltail = "<p><p>Report created for: " + ($OrgData.DisplayName) + "</p><p>" +"<p>Number of Microsoft 365 Groups with plans: " + $GroupsWithPlans.count + "</p>" +"<p>Number of individual Plans: " + $CountOfPlans + "</p>" +"<p>Number of individual Tasks: " + $CountOfTasks + "</p>" +"<p>Number of Completed Tasks: " + $CountOfCompletedTasks + "</p>" +"<p>Percentage of Completed Tasks: " + $PercentCompletedTasks + "</p>" +"<p>Percentage of Active Tasks: " + $PercentActiveTasks + "</p>" +"<p>-----------------------------------------------------------------------------------------------------------------------------" +"<p>Microsoft 365 Groups and Plans <b>" + $Version + "</b>"$HtmlReport = $HtmlHead + $HtmlReport + $HtmlTail$HtmlReport | Out-File $HtmlReportFile -Encoding UTF8$Report | Export-CSV $CSVReportFile -NotypeinformationClear-HostWrite-Host "Finishing processing plans. Here's what we found"Write-Host "------------------------------------------------"Write-Host ""Write-Host ("Microsoft 365 Groups with Plans: {0}" -f $GroupsWithPlans.count)Write-Host ("Number of individual Plans: {0}" -f $CountOfPlans)Write-Host ("Number of individual Tasks: {0}" -f $CountOfTasks)Write-Host ("Number of Completed Tasks: {0}" -f $CountOfCompletedTasks)Write-Host ("Percentage of Completed Tasks: {0}" -f $PercentCompletedTasks)Write-Host ""Write-Host ("The output files are {0} (HTML) and {1} (CSV)" -f $HtmlReportFile, $CSVReportFile)
Parameters
ParameterDefaultNotes
-TenantId""Microsoft Entra tenant ID for app-only Graph authentication.-AppId""Application (client) ID for the app registration used to connect.Attribution
Author
Office365itpros