From 5c86060743c6f28b72be5504e307abd6ab426f17 Mon Sep 17 00:00:00 2001 From: Clayton Tyger Date: Sat, 16 Dec 2023 05:26:32 -0500 Subject: [PATCH] Added new features for Adding Teams and Channels --- 365AutomatedLab.psd1 | 5 +- .../Private/Verify-CT365TeamsCreation.ps1 | 16 ++ Functions/Public/New-CT365Teams.ps1 | 222 +++++++++--------- LabSources/365DataEnvironment.xlsx | Bin 16477 -> 16635 bytes 4 files changed, 124 insertions(+), 119 deletions(-) create mode 100644 Functions/Private/Verify-CT365TeamsCreation.ps1 diff --git a/365AutomatedLab.psd1 b/365AutomatedLab.psd1 index 98679cc..b8803ef 100644 --- a/365AutomatedLab.psd1 +++ b/365AutomatedLab.psd1 @@ -12,7 +12,7 @@ RootModule = '365AutomatedLab.psm1' # Version number of this module. -ModuleVersion = '2.0.0' +ModuleVersion = '2.2.0' # Supported PSEditions CompatiblePSEditions = 'Core' @@ -96,7 +96,8 @@ FunctionsToExport = @( 'Set-CT365SPDistinctNumber', 'Remove-CT365AllDeletedM365Groups', 'Export-CT365ProdGroupToExcel', - 'Export-CT365ProdTeamsToExcel' + 'Export-CT365ProdTeamsToExcel', + 'Verify-CT365TeamsCreation' ) diff --git a/Functions/Private/Verify-CT365TeamsCreation.ps1 b/Functions/Private/Verify-CT365TeamsCreation.ps1 new file mode 100644 index 0000000..101229f --- /dev/null +++ b/Functions/Private/Verify-CT365TeamsCreation.ps1 @@ -0,0 +1,16 @@ +function Verify-CT365TeamsCreation { + param( + [string]$teamName, + [int]$retryCount = 5, + [int]$delayInSeconds = 10 + ) + + for ($i = 0; $i -lt $retryCount; $i++) { + $existingTeam = Get-PnPTeamsTeam | Where-Object { $_.DisplayName -eq $teamName } + if ($existingTeam) { + return $true + } + Start-Sleep -Seconds $delayInSeconds + } + return $false +} \ No newline at end of file diff --git a/Functions/Public/New-CT365Teams.ps1 b/Functions/Public/New-CT365Teams.ps1 index 0011617..094017a 100644 --- a/Functions/Public/New-CT365Teams.ps1 +++ b/Functions/Public/New-CT365Teams.ps1 @@ -1,51 +1,36 @@ <# .SYNOPSIS -Creates new Microsoft 365 Teams and channels based on data from an Excel file. +Creates new Microsoft Teams and associated channels based on data from an Excel file. .DESCRIPTION -The New-CT365Teams function connects to SharePoint Online and creates new Microsoft 365 Teams and channels using the PnP PowerShell Module. -The teams and channels are defined in an Excel file provided by the user. +The New-CT365Teams function connects to Microsoft Teams via PnP PowerShell, reads team and channel information from an Excel file, and creates new Teams and channels as specified. It supports retry logic for team and channel creation and allows specifying a default owner. The function requires the PnP.PowerShell, ImportExcel, and PSFramework modules. .PARAMETER FilePath -Specifies the path to the Excel file that contains the teams and channels information. -The Excel file should contain a worksheet named "Teams". -This parameter is mandatory and can be passed through the pipeline. +Specifies the path to the Excel file containing the Teams and channel data. The file must be in .xlsx format. .PARAMETER AdminUrl -Specifies the SharePoint Online admin URL. -If not provided, the function will attempt to connect to SharePoint Online interactively. +Specifies the SharePoint admin URL for the tenant. The URL must match the format 'tenant.sharepoint.com'. -.PARAMETER ChannelColumns -Specifies the columns in the Excel file that contain the channel names. -By default, it looks for columns named "Channel1Name" and "Channel2Name". -You can specify other column names if your Excel file is structured differently. +.PARAMETER DefaultOwnerUPN +Specifies the default owner's User Principal Name (UPN) for the Teams and channels. .EXAMPLE -New-CT365Teams -FilePath "C:\path\to\teams.xlsx" -AdminUrl "https://contoso-admin.sharepoint.com" +PS> New-CT365Teams -FilePath "C:\TeamsData.xlsx" -AdminUrl "contoso.sharepoint.com" -DefaultOwnerUPN "admin@contoso.com" -This example connects to the specified SharePoint Online admin URL, reads the teams and channels from the provided Excel file, and then creates the teams and channels in Microsoft 365. - -.EXAMPLE -$filePath = "C:\path\to\teams.xlsx" -$filePath | New-CT365Teams - -This example uses pipeline input to provide the file path to the New-365Teams function. +This example creates Teams and channels based on the data in 'C:\TeamsData.xlsx', using 'admin@contoso.com' as the default owner if none is specified in the Excel file. .NOTES -Please submit any feedback and/or recommendations -Prerequisite : PnP.PowerShell, ImportExcel, PSFramework, Microsoft.Identity.Client modules should be installed. +- Requires the PnP.PowerShell, ImportExcel, and PSFramework modules. +- The Excel file should have a worksheet named 'teams' with appropriate columns for team and channel data. +- The function includes error handling and logging using PSFramework. #> function New-CT365Teams { [CmdletBinding()] - param ( - # Validate the Excel file path. + param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateScript({ switch ($psitem){ - {-not([System.IO.File]::Exists($psitem))}{ - throw "Invalid file path: '$PSitem'." - } {-not(([System.IO.Path]::GetExtension($psitem)) -match "(.xlsx)")}{ "Invalid file format: '$PSitem'. Use .xlsx" } @@ -56,116 +41,119 @@ function New-CT365Teams { })] [string]$FilePath, - [Parameter(Mandatory=$false)] + [Parameter(Mandatory)] [ValidateScript({ - if ($_ -match '^[a-zA-Z0-9]+\.sharepoint\.[a-zA-Z0-9]+$') { - $true - } else { - throw "The URL $_ does not match the required format." - } - })] + if ($_ -match '^[a-zA-Z0-9]+\.sharepoint\.[a-zA-Z0-9]+$') { + $true + } + else { + throw "The URL $_ does not match the required format." + } + })] [string]$AdminUrl, - - [Parameter(Mandatory=$false)] - [string[]]$ChannelColumns = @("Channel1Name", "Channel2Name") + + [Parameter(Mandatory)] + [string]$DefaultOwnerUPN ) - begin { - # Import required modules. - $ModulesToImport = "ImportExcel","PnP.PowerShell","PSFramework","Microsoft.Identity.Client" - Import-Module $ModulesToImport - + # Check and import required modules + $requiredModules = @('PnP.PowerShell', 'ImportExcel', 'PSFramework') + foreach ($module in $requiredModules) { try { - # Connect to SharePoint Online. - $connectPnPOnlineSplat = @{ - Url = $AdminUrl - Interactive = $true - ErrorAction = 'Stop' + if (-not (Get-Module -ListAvailable -Name $module)) { + throw "Module $module is not installed." } - Connect-PnPOnline @connectPnPOnlineSplat - } - catch { - # Log an error and exit if the connection fails. - Write-PSFMessage -Message "Failed to connect to SharePoint Online" -Level Error - return - } - - try { - # Import site data from Excel. - $SiteData = Import-Excel -Path $FilePath -WorksheetName "Teams" - } - catch { - # Log an error and exit if importing site data fails. - Write-PSFMessage -Message "Failed to import SharePoint Site data from Excel file." -Level Error + Import-Module $module + } catch { + Write-PSFMessage -Level Warning -Message "[$(Get-Date -Format 'u')] $_.Exception.Message" return } } - process { - foreach ($team in $SiteData) { - Write-PSFMessage -Message "Processing team: $($team.TeamName)" -Level Host - - $existingTeam = Get-PnPTeamsTeam | Where-Object { $_.DisplayName -eq $team.TeamName } - - # If the team does not exist, create it. - if (-not $existingTeam) { + try { + Connect-PnPOnline -Url $AdminUrl -Interactive + } catch { + Write-PSFMessage -Level Error -Message "[$(Get-Date -Format 'u')] Failed to connect to PnP Online: $($_.Exception.Message)" + return + } + + try { + $teamsData = Import-Excel -Path $FilePath -WorksheetName "teams" + $existingTeams = Get-PnPTeamsTeam + } catch { + Write-PSFMessage -Level Error -Message "[$(Get-Date -Format 'u')] Failed to import data from Excel or retrieve existing teams: $($_.Exception.Message)" + return + } + + foreach ($teamRow in $teamsData) { + try { + $teamOwnerUPN = if ($teamRow.TeamOwnerUPN) { $teamRow.TeamOwnerUPN } else { $DefaultOwnerUPN } + $existingTeam = $existingTeams | Where-Object { $_.DisplayName -eq $teamRow.TeamName } + + if ($existingTeam) { + Write-PSFMessage -Level Host -Message "[$(Get-Date -Format 'u')] Team $($teamRow.TeamName) already exists. Skipping creation." + continue + } + + $retryCount = 0 + $teamCreationSuccess = $false + do { try { - $newPnPTeamsTeamSplat = @{ - DisplayName = $team.TeamName - Description = $team.TeamDescription - Visibility = 'Private' - ErrorAction = 'Stop' + $teamId = New-PnPTeamsTeam -DisplayName $teamRow.TeamName -Description $teamRow.TeamDescription -Visibility $teamRow.TeamType -Owners $teamOwnerUPN + if (Verify-CT365TeamsCreation -teamName $teamRow.TeamName) { + Write-PSFMessage -Level Host -Message "[$(Get-Date -Format 'u')] Verified creation of Team: $($teamRow.TeamName)" + $teamCreationSuccess = $true + break + } else { + Write-PSFMessage -Level Warning -Message "[$(Get-Date -Format 'u')] Team $($teamRow.TeamName) creation reported but not verified. Retrying..." } - - New-PnPTeamsTeam @newPnPTeamsTeamSplat - Write-PSFMessage -Message "Successfully created Team: $($team.TeamName)" -Level Host + } catch { + Write-PSFMessage -Level Warning -Message "[$(Get-Date -Format 'u')] Attempt $retryCount to create team $($teamRow.TeamName) failed: $($_.Exception.Message)" } - catch { - Write-PSFMessage -Message "Failed to create team $($team.TeamName): $_" -Level Error - continue # Skip to the next team in case of error. - } - } - - # If the team already exists or was just created, log a message. - Write-PSFMessage -Message "Team $($team.TeamName) exists or was just created. Proceeding to create channels..." -Level Host - - # Retry mechanism to fetch team details up to 3 times. - $retryCount = 0 - $maxRetries = 3 - $teamResult = $existingTeam ?? $null - - while ($retryCount -lt $maxRetries -and (-not $teamResult)) { - Start-Sleep -Seconds 15 # Wait before fetching the team details. - $teamResult = Get-PnPTeamsTeam | Where-Object { $_.DisplayName -eq $team.TeamName } $retryCount++ - } - - # If the team wasn't found after all retry attempts, log a warning and skip to the next team. - if (-not $teamResult) { - Write-PSFMessage -Message "Team $($team.TeamName) was not found after $maxRetries attempts." -Level Warning + Start-Sleep -Seconds 5 + } while ($retryCount -lt 5) + + if (-not $teamCreationSuccess) { + Write-PSFMessage -Level Error -Message "[$(Get-Date -Format 'u')] Failed to create and verify Team: $($teamRow.TeamName) after multiple retries." continue } - - # Create channels based on the provided column names. - foreach ($column in $ChannelColumns) { - $channelName = $team.$column - if (-not $channelName) { continue } # Skip to the next channel if the name is not found. - - Write-PSFMessage -Message "Creating channel: $channelName for team: $($team.TeamName)" -Level Host - try { - Add-PnPTeamsChannel -Team $teamresult.GroupId -DisplayName $channelName -Description "Channel named $channelName for $($team.TeamName)" - Write-PSFMessage -Message "Successfully created channel: $channelName for team: $($team.TeamName)" -Level Host - } - catch { - Write-PSFMessage -Message "Failed to create channel $channelName for team $($team.TeamName): $_" -Level Error + + for ($i = 1; $i -le 4; $i++) { + $channelName = $teamRow."Channel${i}Name" + $channelType = $teamRow."Channel${i}Type" + $channelDescription = $teamRow."Channel${i}Description" + $channelOwnerUPN = if ($teamRow."Channel${i}OwnerUPN") { $teamRow."Channel${i}OwnerUPN" } else { $DefaultOwnerUPN } + + if ($channelName -and $channelType) { + $retryCount = 1 + $channelCreationSuccess = $false + do { + try { + Add-PnPTeamsChannel -Team $teamId -DisplayName $channelName -Description $channelDescription -ChannelType $channelType -OwnerUPN $channelOwnerUPN + Write-PSFMessage -Level Host -Message "[$(Get-Date -Format 'u')] Created Channel: $channelName in Team: $($teamRow.TeamName) with Type: $channelType and Description: $channelDescription" + $channelCreationSuccess = $true + break + } catch { + Write-PSFMessage -Level Warning -Message "[$(Get-Date -Format 'u')] Attempt $retryCount to create channel $channelName in Team: $($teamRow.TeamName) failed: $($_.Exception.Message)" + $retryCount++ + Start-Sleep -Seconds 10 + } + } while ($retryCount -lt 5) + + if (-not $channelCreationSuccess) { + Write-PSFMessage -Level Error -Message "[$(Get-Date -Format 'u')] Failed to create Channel: $channelName in Team: $($teamRow.TeamName) after multiple retries." + } } } + } catch { + Write-PSFMessage -Level Error -Message "[$(Get-Date -Format 'u')] Error processing team $($teamRow.TeamName): $($_.Exception.Message)" } } - end { - # Disconnect from PnP + try { Disconnect-PnPOnline - Write-PSFMessage "Teams and Channels creation completed." + } catch { + Write-PSFMessage -Level Error -Message "[$(Get-Date -Format 'u')] Error disconnecting PnP Online: $($_.Exception.Message)" } -} \ No newline at end of file +} diff --git a/LabSources/365DataEnvironment.xlsx b/LabSources/365DataEnvironment.xlsx index 4662d72b62c24dacff324b0580096f89c3789dbf..90ec47751eed436583e168222e8796b1953b0a26 100644 GIT binary patch delta 6297 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 NSe6k(Q|e#t{{T(-1rGoK delta 6121 zcmZ8lWl$7c*j+$s>0YH-xCPnu1f*LUNm)X=^YeYb zAMbqM{qfA1InT_!GtYhIoH@6lXv-mJ)yr5A7)_tUAO--Cf(rl;0RR9$Cq7?S4@YZP zS4UnyXQy&~XRSqX@`vEUTl)M!qMFkMB?JfGpEtO~oU~4)q#k4`hRp6&8{HJ>sF#ql}}|R7f7#h8F!5)#wDtx#$wl*_VsZO!%N$;hXPo7P*wB%hGpk!fPq_vj77(~v$`o3F4V}+ zmS;v4ys9NGrQ;1@(;k@us&xqmJ&3BE49to0{78yw#}D#D6C-siqXu$As6@!~h|KT= z3IAezqR*9bVFKN9x;iAPZ_QPY)_5yB&rOGDw`pi9M^F#~GIAi^9cE3p%47)lE*Fz$ z1xc?8EWCA0J=mTSmnT+Y(X&U%qgXgS3*Ji`Sos00Yy9hvSQ1TgFTGz<0Vf)(jb)j~ z?R}82Y&nj&jJaxF*QLD&p)bTkBYQRS8}@ut*_D!iOR&K!%399yfDZEUm^sM2OBPx8Z#(7MYbQ<{l zlDzSr0@0{d;o!LLNT81%WZJMIyw$>r?)@?KouOhg(F3>Nmm51+q~`3Gic8ecVV_qA zb|k>R`kqd*s9@>6&&4;0GsVGPbL7o3wtT+-??&Pg-f09m$8?2%{RDwPZRD;~OTu=8 zQ;xPEzHTfn`HdY4WarcxDFia^`&PpP_91oLzuB;%K@M8)W>MbJXSG)>%t4CMqdfnz26-QW%}e{Mu$`VThs%Yl&V zt`2ejMGHM9YLr;%a{Z}nJbc-qvw4YkzwP{M(33iat)R=jUuac~H(_+R*-JFIP(Hrj zRLa(4{inB!12wBhOlsX5$7@cM!8x!_Z{^fZb<{=o%-q8CzLUoVSzS9&h74Hg9T*q! zci*#t@XTjV;q-J^5cuM!41VIsL#}gL?q8n#QBGlUZ!%cs56osZ8s4f`hY2)f!ApZK z2}i9A)buBkLIn6vM;9?`*kAu{dZmi+OY+Ds zvbq!NWQLvefzh~j}SFONE zWk%xoSEv|-QOyRd&EDhgiRK93EJpG;L$i-xYloK!q$TvBTxWo*#d)8q;qiNKOXIjO zX2yM{%~s87WLJpXTN#hqGzS2h0kh&`QCG-Bk7_Bq^RmJ1gec1n&dEe9bt}0P8MyJq zn}M9|a`Uw$^hsLG^@lU*bbQ`O4fkH+v0mmgRniAo$mVc}pB3{1EiT*fvK!UsosY72 zc$@SW5an&T=#*}+-0*LI`sx2lz~ke;1%c4{6vj`k*d*5iNKoq%NK#PyBaS1#o+4E9 z{i&~vR=?rli{K+=(L)>>x&=Q_f5~1^M@>;x&VIdq#NfIw1nN;=qJLq6xUar$^Lk6u zw^6S@6d_z%)8p;qOWVPLo}VctV$A0p3~_OiUV3kw_8}ir7rS0iMZ)WaF3cs~3w8js z2N)LxjI#86iO8Oki_FQcD*&qOh|!Lh<^xfr=cvjO^pgex zJvZY5hci`-!bViT7!}{3vXdoRaHq&n*g?l(AggxXBXQX&m;J!^2!JQhI@Vn?c4I)g zsE9i>u>pWS1^|HUf0fhA&eqwM@82r$k9zJKjl@xlkatqu$l!bW-o4m>v8=3)Il-5i zZL$zF4UPLcZ-fLB+o;LVS;WqD*$WC&2D?PoAt=K3A_3AT|raK#pLhLf7H z4?&#rLwixtWp=RtsTbsUvOU#bITf!vG1;O+`$Q@eI+w~kl?-tdWq%&1rGkKx*y6fe z<8j};TFeRdAaNeR>7T>6R&dHordJhP*-t|ozLHAG zRj6A50w-RXhgoX}1PL;N_X(2JAOR(9T5AFcas*KJUk0T$ERejKjhPX|UZj%l$T06CdTo8&-uaEF*cB+($vAx@Dl@gy+dlm%}6jPPlPE zp#?^VybWD%O(^RT3^jdS9ANiLtQ2()iz@>&fJ^@k`|#x2b1khG<+h}qoN zkm^W!{FYTkJV@0`4r$Y#sH}HI@AqbN$t_HJW?%g=*KRs<)K*aSXpZEKXmIdFE>PHX zqdt+X;D^6#PvjWW`Akr@udAnS$Rx653_TE(285vNGOMl+^x|uc8ob147QC`*F! zDLxqbfetY@Vc6~VuKv@#NMqQC*(TZPINw5ljAZ_lR;5&fx%3w51M(GLElnRfI&#Za zUxuF|V_IXz6C4kC)ye^v+d{bp>$>yt)waJ0Vo77Zigr!vb=Js3Y0FyWqqkiXCU90&T!ne;>jh99^>2H)O}F zv#%?rAn<~=BhF@1&;!z?%v#CEtl%HZaeDKX`{S$M;!)?%UUmZUHwbU(%)SdJzVbj# zBI7_p2~s|D@!FD}3+jnKz6Y}sGAbtchO1Hq!saCuq~vL|YXz2?a2vbt-!U~~ZaSnR z*dWn2wxhH)ZP2uKdMdB5>Nwj4$;P>ckr*Kf@zau|g;tQB7qkx)&?N0?IBq30w2hoF zV6sf2-B&B;`Z211o@t3@n^@}jlfxqghs*Zz7~x#~-qk(KkCHuxA4;K?Xs7?xh0l+{`{ctX+CrHcw72z%3Qk2b`;{6 zc52*resTWNA+0SibddG%1eLATR}{3v4SLcx0mbGoUesw}Lpq?1GF_#= zNabEafO$6DS!LJ+7hvP~#`3ny-!fy>&tLlqvF5%li1Oy5$QiG=)jais#ZPIzN0zsw zm*8P0Ui)#F#>=OueMK2d9j;VN??Hl5uYHYDu-NCPKvwGSsCB$SIu?X-h zPIwtkXz!-v87sMO0r{ONDEziARu$W~qFye%jC@Cf*nUeNO487@ zt@(r)U|xFjE$$y2yzNWd6$^O4P_Mdp{z?8Ysx@wjk>p#WzLvD8X+bTdH3UiRA+#G$ zMB=E(#MT_j%Bna1UBczM!2zd<6a3uqnhXW%nf1d$f~QTW^7Wd1o~mZMT=`lhKc%XT zQb35C!Zio61QJpM5Z`$$cpymx&nIZ|ow*D`h^37;(q*~mGu(;2B!2h_c7g39mq9w& zn)IP-dX;VurMcs@vwFb@of>30z*SSF6>V+A#vdzyCVXj8PyaCTi3qp zmVJL9p&aj|{QJZE@ zY+aqsV!aSI4!+j3@RhT-lBm>iC7aBTtYKqsS?)cxtgD;BrM8)O*uJ5X{%#P_B3+cW zoNEf0{brK-2SV5~ZV7G_kMpZdjE(0pn8OKsS#6F7@yx1|_;t8$9!XuYFyFBzN&wg_h4g-XC#6oE>7KIn5tpzX1&97KG6M;gdyv; zVKCRo*~cN`U4s<+lXlH7I`|LF@PE& zZ!_1h000OLJeQvaVxqSpOdfow{us`7JNz4+aU9JDD)2eNbXT-h-aD2f`Oa0m3tMZ1 zel};*_j}*eJRg(756W%vA5O+r=h= zMBa-_=P)Lu25(VzW8xM-w}&XS#2T|;S3r1?lf0O7sbb&RJK@+O6s1TErC8y@rkV9= zZwAd%J5WG>QP3f8`Y_2oaH{mjUfbFvt+zCE&jLLO@>B~*fVL5zs?wgJ9A+5vF_A~N zqr8I^#|lQU0S*)mJ_#wyn2+aw6Q}snh>#cShs-xjdaPg!DhAKEvnPUAM;A4r!#`kj z-F~7So&ol+6;}_)203gM{#rdjr8S8GGVf9QEFA1MNh8bjQUmvZV-ripqhOLb|zmW=8`kmd!EDJ_hCPwP1U7 zX%n1aNhede+!1|f^2$0v|A&aHu5ySsJ_w9-*yASiF>9J=FrJ2~d0>gS%S4P?JYKY_ z=yeHBf2eBkR~BT!+x5!GB>js0JYuO@B$QpALJ5wq@)DOjMVqUJ>DajIN(JIQmdO@Y zsQr1*vT9Lc_wrVGdF40;Z!d>%bazhmaT;%GID;`3T%9hfPcE{r`D@_H52gdn(sOkwFGGGk9CTv)&0&6eL@Grh;EB#AIZXYxj@?pG+a+gyofp=!2#>2HPRHGRAQ$Cst&Z&sf$HCv@FsjAe90`9YKQ*d_Hc@GUtrZSZKO*XXm?D^>_%`4N^A zzk+1ra^QpS@}>~K@R%ed4cCa!dYJQdb6%`y9To4TjT?Q>10CqK7N->R@na4fjs~WUefO3g09mF7+&SR!@}wy zNoV_*xP6axdLR%x0ky?^M^2UQw~npWUB{zQBE9xodafU2BxMwfkSqZ1N9U?kM$g-1 z=SBN5{AAwpG*Q(nsJ4->Xbbo$70#xctsAtWMWvp9GkMsu=Lw0>aR??u3}stVM+r-; zu15j|P{;u52px1u0gPU{sCkW-~ojgA|M|M#he6Xez1?8!1(OT)XRQSHjPYP|5ZwDGMl9wvxe97-(7n4GIzc z+@NFM4b8X!&%Q3BD$ejT8LZ8WNn)n+tde z-TwLYGpu7LP*kcD%Yv_pjs9GMO1tfI-_qYRoc2z&C{Pk9L#AOM;-5HgnbY|yczVj5Fx-ULdox9zZDfuV|7)x(} zpwDiJx5-@MTX)`mukdhXYtQ%i>t!wC&FP8R!iTS2oHAL5VooB^q2{~?`77i%pCC8- z7o5;+B>?D(QL@sh+OS6S zy$l}0Px+*9<>998LF73aq`-S!ZZQ-tB}QD%-Vw1`$t7&Bns4^lZr?ORh)M4`Hjv`U zt7?F)k8NS;G=svQh|UGn+u0Nj;kT1#91&vvbm#f7Z1+Q(h_d2juk3Zb`g5#o^{Fs- zin(v_dfWVw@fBpUl~XSrc~gR~9pAyTFU_aQdh5w?MCRz9aFM-02u;e8yCk5;hbf;- zHx_q3INkq=N@yMy=7NzrI2O`I2(5*fgObwp^ebJ1iu9JmOk%%?B8eK)p=Ps+W`cc3 z*Brr%($X;f9yOv?(vXwaKvsOW+0@Ke`a9%ftUVVFF3Ugp{0&0e4o|&D0%lRUs8{?P z56(y5Yrc`-2_7X#%+-7|`k?WXs26KXAO`-KyW3CaGYc5E)u@B$&|-<6i4J=gu`a_D zFP}(`5LBBB8;yQ1EB(9Uj`CUr9?DNq7b0$ef%$J74quYeMeBp}Nh_mM62NnxQNZJ+ z`Ov2+{)JP2Vbrs~@EM#=hM4BRJPiN<{Y?S>JIA#D_FKsCp_Rj7GF;UEKQQ>O)dfBy zBa8lr8%`xlPyL^h0RYJV>-mc$;5xEw)c;Ie>#qrViw*#g{2yKu4wscipB95p$ueP8 IN&dtA4`-jIa{vGU