U2U Blog

for developers and other creative minds

TextBox

SharePoint Framework (SPFx): Using the new Microsoft Graph client from an application extension

Using the Microsoft Graph client from an Application Customizer Extension

In this post I will show you how to use the Microsoft Graph client from within an application extension. We will show the latest message from group conversations within an Office 365 group inside the header placeholder of it's SharePoint site. All these features are currently under preview. Documentation is available here: Release Notes Extensions Dev Preview Drop 1.

Start with a new project, using the Yeoman generator for SharePoint. Run the following from your command line tool:

cd c:\
md demo_graphextension
cd .\demo_graphextension\
yo @microsoft/sharepoint

Provide the wizard with the following details:

     _-----_
    |       |    .--------------------------.
    |--(o)--|    |      Welcome to the      |
   `---------´   |  SharePoint Client-side  |
    ( _´U`_ )    |    Solution Generator    |
    /___A___\    '--------------------------'
     |  ~  |
   __'.___.'__
 ´   `  |° ´ Y `

Let's create a new SharePoint solution.
? What is your solution name? demo-graphextension
? Which type of client-side component to create? Extension (Preview)
? Which type of client-side extension to create? Application Customizer (Preview)
? What is your Application Customizer (Preview) name? GroupConvNotifier
? What is your Application Customizer (Preview) description? ""

Let's first verify the extension actually works. Preview you know ;).

First run this command to make the debug files available on localhost:

gulp serve --nobrowser

To debug/test the extension we will have to use the modern UI in an actual SharePoint site. We will need the generated extension ID, so grab it from GroupConvNotifierApplicationCustomizer.manifest.json. Open one of your SharePoint Online sites and add the following to the url, replacing the id with the one you just copied:

?loadSPFX=true&debugManifestsFile=https://localhost:4321/temp/manifests.js&customActions={"6136b9d4-dd9e-45c2-81cc-f4ec789bb53c":{"location":"ClientSideExtension.ApplicationCustomizer"}}

Confirm the load of the debug scripts and verify you get the following result: 



Let's now start with the code for our extension.

Getting the data

We will start with a service that will allow us to grab the latest conversation data from the group we are currently in. Add a folder services to the src folder. In there, add a file MSGraphService.ts and add a class.

export default class MSGraphService {

}

The first thing we will need is the current group id. For this we will need the context from the application customizer. We will pass the context into the constructor. The type ApplicationCustomizerContext you can find if you look at the BaseApplicationCustomizer class your Extension inherits from.

import the BaseApplicationCustomizer type at the top of the MSGraphService.ts file

import { ApplicationCustomizerContext } from "@microsoft/sp-application-base";

Inside the class, create a private field for the group id and set it from the constructor.

private _groupId = null;

constructor(public context:ApplicationCustomizerContext){
    this._groupId = this.context.pageContext.legacyPageContext.groupId;
}

Now that we have the context and the group id, we can get some actual Graph data. To the context object, Microsoft has now added the graphHttpClient object, which we can use to query for group data and reports. This will be extended in the future. Before, we had to use nasty solutions with popup's and/or iframes to get Microsoft Graph data!!

First, let's create a data structure that will be able to store the objects comming from Graph. Add a new folder interfaces to the src folder. In the interfaces folder, create a file IThread.ts. We will add two interfaces: one for the thread object and one for it's posts.

export interface IThread{
    id:string;
    topic:string;
    lastUpdate:Date;
    posts:IPost[];
}

export interface IPost{
    id: string;
    from: string;
    content: string;
}

import the interfaces inside the MSGraphService.ts file

import { IThread } from "../interfaces/IThread";

If you want to verify the data structure first, go to the Microsoft Graph Explorer and test the following queries to get to the query we need:

https://graph.microsoft.com/v1.0/groups/
https://graph.microsoft.com/v1.0/groups('{groupid}')
https://graph.microsoft.com/v1.0/groups('{groupid}')/threads
https://graph.microsoft.com/v1.0/groups('{groupid}')/threads?$select=id,topic,lastDeliveredDateTime&$top=1
https://graph.microsoft.com/v1.0/groups('{groupid}')/threads?$select=id,topic,lastDeliveredDateTime&$top=1&$expand=posts($select=from,body,receivedDateTime)

Notice that it is not possible to only take the latest post in one query, because sort and top is not supported in expand just yet. To avoid two REST calls, I'm grabbing the data in one go and will get the last post from code.

Import the necessary types for the graph client first:

import { GraphHttpClient, GraphClientResponse } from "@microsoft/sp-http";

Implement a method getLatestThreadPost that will get the group's latest conversation thread and it's post data based on our Graph URI.

public getLatestThreadPost():Promise<IThread>{        
    return this.context.graphHttpClient
        .get(`v1.0/groups/${this._groupId}/threads?$select=id,topic,lastDeliveredDateTime&$top=1&$expand=posts($select=from,body,receivedDateTime)`, GraphHttpClient.configurations.v1)
        .then((response:GraphClientResponse) => response.json())
        .then((jsonData) => {
            let tData = jsonData.value[0];
            console.log("Got conversation info");
            console.log(tData);
            return {
                id:tData.id,
                topic:tData.topic,
                lastUpdate:tData.lastDeliveredDateTime,
                posts: tData.posts.map((post) => {
                    return {
                        id : post.id,
                        from : post.from.emailAddress.name,
                        content : post.body.content
                    };
                })
            };
        })
        .catch((error) => {
            console.log("something went wrong");
            console.log(error);
            return null;
        });
}

That's it for the service! Notice that we do not need to generate any access tokens. The authorization is done by the graphHttpClient in the background, so great for us lazy developers! :) For the sake of the demo, we are assuming we have at least one thread and one post, of course we could add some validation here to check this first.

Rendering the data

Now that we have our service ready, let's show the data in the header placeholder of our Office365 Group SharePoint site.

We would like to get the latest data before we actually render the extension. For this we can override the OnInit method of our extension base class. Notice that the OnInit is already overridden in the generated code, so let's modify it. Open GroupConvNotifierApplicationCustomizer.ts.

First, import the necessary types:

import MSGraphService from "../../services/MSGraphService";
import { IThread, IPost } from "../../interfaces/IThread";

Now add two private fields to store our service and the latest data:

private _graphService: MSGraphService;
private _latestThreadData: IThread;

Next, override the OnInit method with the following code:

@override
public onInit(): Promise<void> {
  this._graphService = new MSGraphService(this.context);
  return new Promise<void>((resolve, reject) => {
    this._graphService.getLatestThreadPost().then((postData) => {
      this._latestThreadData = postData;
      resolve();
    });
  });
}

Notice this method will wait for our promise to resolve. The extension will wait for the OnInit method before rendering, ensuring our data is loaded. If you want to test the data load, add the following line to the onRender method:

console.log(this._latestThreadData);

Test this by attaching the same query string to the url of your Office365 Group Site. The result, if you open up your console, should look like this: 


To render the data, we will write a method renderHeader inside the GroupConvNotifierApplicationCustomizer class. Add the PlaceHolder type as an import:

import {
  BaseApplicationCustomizer,
  Placeholder
} from '@microsoft/sp-application-base';

First, let's add a private field for the Header placeholder to the class:

private _headerPlaceholder: Placeholder;

Change the onRender method to the following:

@override
public onRender(): void {
  console.log(this._latestThreadData);
  this.renderHeader();
}

Implement the renderHeader method as follows:

private renderHeader(): void {
  console.log('Rendering header!');
  // Handling the header placeholder
  if (!this._headerPlaceholder) {
    this._headerPlaceholder = this.context.placeholders.tryAttach(
      'PageHeader',
      {
        onDispose: this._onDispose
      }
    );

    // The extension should not assume that the expected placeholder is available.
    if (!this._headerPlaceholder) {
      console.error('The expected placeholder (PageHeader) was not found.');
      return;
    }

    if (this._latestThreadData) {
      let lastPost: IPost = this._latestThreadData.posts[this._latestThreadData.posts.length - 1];

      if (this._headerPlaceholder.domElement) {
        this._headerPlaceholder.domElement.innerHTML = `
            <div class="${styles.app}">
              <div class="ms-bgColor-themeTertiary ms-fontColor-white ${styles.header}">
                <i class="ms-Icon ms-Icon--Info"></i>
                &nbsp;${this._latestThreadData.topic}
                &nbsp;<i class="ms-Icon ms-Icon--Contact"></i>
                &nbsp;${lastPost.from}
                &nbsp;<i class="ms-Icon ms-Icon--Message"></i>
                &nbsp;${this.parseContent(lastPost.content)}
              </div>
            </div>`;
      }
    }
  }
}

The onDispose is required when attaching to a placeholder, so implement it:

private _onDispose(): void {
  console.log('Disposed header.');
}

Notice also that for the content of the post, which is typically HTML, we use a method parseContent. This method will strip the HTML tags out of the text and limit the maximum length of the content to 200. Implement it like this:

private parseContent(content:string):string{
    let regex = /(<([^>]+)>)/ig;
    content = content.replace(regex, "");
    if(content.length > 200) content = content.slice(0,200);
    return content;
}

As a final step, we will have to implement the styling used in our extension!
Most of the classes used are Office-UI-Fabric css classes, but some are custom. For these, create a new file GroupConvNotifier.module.scss inside the groupConvNotifier folder and add the following content:

.app {
  .header {
    height:40px; 
    text-align:center; 
    line-height:2.5; 
    font-weight:bold;
    display: flex;
    align-items: center;
    justify-content: center;
  }
}

import the styles at the top of the file:

import styles from './GroupConvNotifier.module.scss';


That's it! Now test again and check out that magnificent chat message inside the header!


Announcing the SharePoint Add-in “Export to Word”

Today, we’re glad to announce the FREE SharePoint Add-in “Export to Word”, developed by the team at U2U. This add-in fixes one of those issues that you’ll probably will have come across with in SharePoint, namely generating Word documents based on SharePoint list items. A bit like a mail merge for list items!

This functionality has never been out of the box available for regular SharePoint list items. You could achieve something like it on document libraries only, using Quick Parts in Word. But however, that only works for document libraries.

Now, what does the add-in allow you to do? It allows you to configure, per SharePoint list, one or more Word templates. You can even upload your own templates! The add-in then allows you to link fields from your selected list to the content of the template. Once your template is completely designed, you can just go to the SharePoint list and export any item to Word using your template. You can even export multiple items in bulk!

CreateNewTemplate_ori GeneratedDocuments_ori GeneratedDocuments2_ori

Do you want to know more about this add-in? Just go to the official site.
Do you want to install it from the Office Store? You can find the add-in here.

For a quick start guide on how to work with the add-in, have a look at the following video:

 

Do note that this is the first version of the Add-in, and we want your feedback to further extend and improve the add-in. You can contact us via the regular ways.

Use PowerShell and CSOM to batch create SharePoint Online Site Collections with a custom template

Introduction

 

In this post I will show you how to create multiple Site Collections in SharePoint Online using PowerShell and the CSOM (Client Side Object Model) libraries provided by Microsoft. It will also be possible to apply a custom template to the Site Collections that we create.

As at the time of writing the possibilities of the SharePoint Online Management Shell are too limited to do this - especially regarding Sandboxed Solutions and thus applying templates – everything will be done by using the CSOM SharePoint assemblies in PowerShell. We will initialize .NET objects using PowerShell.

These are the steps we want to implement in the PowerShell Script:

  1. First we will check if the Site Collection already exists in SharePoint Online. If it does, we will delete it.
  2. Before we can create a new Site Collection at the URL of a deleted Site Collection, we need to remove it from the recycle bin.
  3. When we are sure no Site Collections exist, let's now create them!
  4. In case we want to apply a custom template, there are a few steps to perform:
    1. Upload the template to the Solutions gallery
    2. Activate the uploaded solution
    3. Apply the uploaded template to the Root Web of the Site Collection
    4. Delete the uploaded template file
  5. Set the security for the site collection

The assemblies we need to load are the following:

  • Microsoft.Online.SharePoint.Client.Tenant.dll
  • Microsoft.SharePoint.Client.dll
  • Microsoft.SharePoint.Client.Runtime.dll
  • Microsoft.SharePoint.Client.Publishing.dll

To get these DLL's, download the SharePoint Server 2013 Client Components SDK here.

The tenant DLL is used for deleting and creating site collections using your SharePoint Online admin URL.
The other DLL's are used to access the created site collections and perform operations on them. The publishing DLL specifically is required to activate sandboxed solutions.

 

Getting started

 

First we'll create a PowerShell cmdlet, which we can then call with a set of parameters to get the Site Collection creation process going. All these parameters have a default value, but can be overwritten when calling the function. Notice the SiteTemplate by default uses the Team Site template ("STS#0"). If you wish, you can get a list of available templates from your tenant as well: link.

The rest of the parameter names should speak for themselves.


function CreateSiteCollections{
    [CmdletBinding()]
    param(
        #security
        [string]$Domain = "*yourid*.onmicrosoft.com",
        [string]$UserName = "*adminusername*@$domain",
        [string]$UserPassword = "*yourpassword*",
        [string]$AdminUrl = "https://*yourid*-admin.sharepoint.com",
        #site details
        [string]$SiteUrl = "https://*yourid*.sharepoint.com",
        [string]$SiteTitle = "My Team Site",
        [string]$SiteOwner = "*siteowner*@$domain",
        [string]$Prefix = "*SiteUrlPrefix*",
        [string]$Suffix = "",
        [string]$SiteTemplate = "STS#0",
        [switch]$IsCustomTemplate,
        #site numbers
        [int]$StartIndex = 1,
        [Parameter(Mandatory=$true)]
        [ValidateRange(1,100)]
        [int]$NumberOfSites = 5,
        [int]$BatchSize = 5
    )
    #We’ll add the rest here
}

Inside this function, the first thing we are going to do is load the CSOM assemblies from their installation path. When installing the SDK the assemblies will be found at C:\Program Files\Common Files\microsoft shared\Web Server Extensions\16\ISAPI – the Tenant DLL assembly - and C:\Program Files\SharePoint Client Components\16.0\Assemblies.
As a first step in the script we will verify that both paths exist.

As we only need the publishing assembly when applying a custom template, we will only load it when the $IsCustomTemplate switch parameter is passed in. When executing this script we are assuming there is a folder Templates at the execution path from which we will load the custom template when required. When $IsCustomTemplate is provided to the function call, we expect the parameter $SiteTemplate to contain the filename of the site template, i.e. "MyCustomTemplate.wsp".

If so desired, you could also create a local folder containing your assemblies and point the Add-Type calls to them.

try{
Write-Host "Loading Assemblies`n" -ForegroundColor Magenta
$ClientAssembyPath = resolve-path("C:\Program Files\Common Files\microsoft shared\Web Server Extensions\16\ISAPI") -ErrorAction Stop
$TenantAssembyPath = resolve-path("C:\Program Files\SharePoint Client Components\16.0\Assemblies") -ErrorAction Stop
Add-Type -Path ($ClientAssembyPath.Path + "\Microsoft.SharePoint.Client.dll")
Add-Type -Path ($ClientAssembyPath.Path + "\Microsoft.SharePoint.Client.Runtime.dll")
Add-Type -Path ($TenantAssembyPath.Path + "\Microsoft.Online.SharePoint.Client.Tenant.dll")

if($IsCustomTemplate){
Add-Type -Path ($ClientAssembyPath.Path + "\Microsoft.SharePoint.Client.Publishing.dll")
$CurrentPath = Convert-Path(Get-Location)
$PackagePath = resolve-path($CurrentPath + "\Templates\" + $SiteTemplate) -ErrorAction Stop
}

}catch{
Write-Host "Can't load assemblies..." -ForegroundColor Red
Write-Host $Error[0].Exception.Message -ForegroundColor Red
        exit
} 

Batch create sites

For this script, I've chosen to create the Site Collections in batches. Calling ExecuteQuery after each operation takes too long, but creating 20 sites with one ExecuteQuery call is just too much for the web service to handle. We'll pick 5 as a default batch size, but feel free to choose what works best for you.
The idea is that we launch for instance 5 site collection creation operations in one go, and then wait for all the operations to complete. This will improve the execution time of our script.

First, we will generate the site collection names and put them in the $SiteNames variable. Each site name is a combination of the $Prefix parameter, the current index and the $Suffix parameter.
We will then grab a batch sized chuck of the site names array and copy them into the $BatchSiteNames variable. We will then perform all required operations for the sites in that batch.

try{
    
$indexes = $StartIndex..($StartIndex + $NumberOfSites - 1)
[string[]]$SiteNames = @() 
$indexes | % { $SiteNames += ($SiteUrl + "/sites/" + $Prefix + $_ + $Suffix) }
Write-Host "The following sites will be created:" -ForegroundColor Magenta
$SiteNames

$CurrentIndex = 0

While($CurrentIndex -lt $SiteNames.Length){ 
      	if($CurrentIndex + $BatchSize -le $SiteNames.Length){
$BatchSiteNames = $SiteNames[$CurrentIndex..($CurrentIndex+($BatchSize - 1))] 
}else{
$BatchSiteNames = $SiteNames[$CurrentIndex..($SiteNames.Length - 1)] 
}
Write-Host "`nProcessing batch of sites $CurrentIndex -> $($CurrentIndex + $BatchSiteNames.Length)" -ForegroundColor Yellow

#Site Creation logic goes here

$CurrentIndex += $BatchSiteNames.Length

}
}catch{
Write-Host "Something went wrong..." -ForegroundColor Red
throw $Error[0]
}

Create Sites

Let's start with the first operation, which is deleting the site collection if it already exists! First, we'll need to get all the existing site collections from the SharePoint Online tenant, then we'll match the url's with the ones we are trying to exist. If we have a match we will delete the site collection.

#create the sites   
Write-Host "`nCreating the site collections`n" -ForegroundColor Magenta    
CreateSites -Sites $BatchSiteNames 

We will split up the logic into functions. Each function will execute one step of the site creation process. First we'll create the CreateSites function. This function will accept a set of site names, namely the ones we want to create in the current batch.

The function should be inside the CmdLet body, in order to have the parameters available.

We need to initialize a SiteCreationProperties object with our site name, url and other configuration properties.

You'll notice that we get a ClientContext object from the GetContext function, so we'll implement that one in the next step. When working with a ClientContext, we first need to perform a ClientContext.Load() on all the objects we want to use and read properties from, followed by an ExecuteQuery() to actually get the objects. With the Load we are queueing things to be loaded, while the EecuteQuery will then execute the load.

When we perform operations on the TenantAdministration.Tenant namespace in CSOM, what we get back is an SpoOperation object. On this object we can check if the operation has completed by reading the IsComplete property. As mentioned before, we will create a batch of operations first, execute the query, and then wait for all the operations to complete. As this can be implemented in a generic way for all the operations we are going to perform we'll write a function WaitForOperations that will do exactly that.

Notice that we will also create a generic ExecuteLoad function, as well as a generic ProcessError function.

function CreateSites($Sites){
        #Set Admin Context
        $Context = GetContext -Url $AdminUrl
        #Get the Tenant object
        $Tenant = New-Object Microsoft.Online.SharePoint.TenantAdministration.Tenant($Context)
        $Operations = @()
        $Sites | % {
            $SiteCreateUrl = $_
            try{
                #Set the Site Creation Properties values
                $properties = New-Object Microsoft.Online.SharePoint.TenantAdministration.SiteCreationProperties
                $properties.Url = $SiteCreateUrl
                $properties.Title = $SiteTitle
                if(!$IsCustomTemplate){
                    $properties.Template = $SiteTemplate
                }
                $properties.Owner = $UserName
                $properties.StorageMaximumLevel = 250
                $properties.UserCodeMaximumLevel = 50
                $properties.TimeZoneId = 3
                $properties.Lcid = 1033
 
                #Create the site using the properties
                Write-Host "Creating site collection at url $SiteCreateUrl"
                $Operation = $Tenant.CreateSite($properties)
                $Context.Load($Operation)          
                $Operations += $Operation
            }catch{
                ProcessError("Error creating site $SiteCreateUrl")
            }
        }

        #$Context.Load($Tenant)
        ExecuteLoad -ExecContext $Context

        WaitForOperations -JobContext $Context -Operations $Operations -Description "Site creation"

        #dispose
        $Context.Dispose()
    } 

Let's impletement the GetContext function next. First we'll get a ClientContext object. Then we will add some Credentials to it, using the parameters that were passed in through the CmdLet.

On the ClientContext, we will change a few settings, as the defaults kept giving me errors.

Some errors that I had before these changes:

  • The underlying connection was closed: A connection that was expected to be kept alive was closed by the server.
  • Service endpoint not found

By switching the http protocol to version 1.0 and disabling KeepAlive on the webrequest, these errors seem to go away. I would have to spend some more time to figure out why these errors occur using the SPO web service.

function GetContext($Url){

$Context = New-Object Microsoft.SharePoint.Client.ClientContext($Url)      
          
$credentials = New-Object Microsoft.SharePoint.Client.SharePointOnlineCredentials(
$UserName, 
$(ConvertTo-SecureString $UserPassword -AsPlainText -Force)
)
        
$Context.Credentials = $credentials
$Context.RequestTimeout = -1
$Context.PendingRequest.RequestExecutor.RequestKeepAlive = $true
$Context.PendingRequest.RequestExecutor.WebRequest.KeepAlive = $false
$Context.PendingRequest.RequestExecutor.WebRequest.ProtocolVersion = [System.Net.HttpVersion]::Version10

return $Context
} 

Next, let's implement the ExecuteLoad function. In this function we will call the ExecuteQuery method on the ClientContext. The reason I put this in a separate function is that it allows us to build our own retry or reload logic when desired.

function ExecuteLoad([Microsoft.SharePoint.Client.ClientContext]$ExecContext, $ReloadObject, $Retry = 0){
        try{
            $ExecContext.ExecuteQuery()
        }catch{
            Write-Host "Something went wrong..." -ForegroundColor Yellow
            Write-Host $Error[0].Exception.Message -ForegroundColor Yellow
            if($Retry -lt 5){
                $Retry += 1
                Write-Host "Retrying ($Retry)..." -ForegroundColor Yellow
                ExecuteLoad -ExecContext $ExecContext -ReloadObject $ReloadObject -Retry $Retry 
            }else{
                ProcessError -Message $Error[0].Exception.Message 
            }
        }
    } 

Once ExecuteQuery has been called, we have to wait for all the operations to complete. For this we'll write the WaitForOperations function. It will accept a collection of SpoOperation objects, on which there is a method RefreshLoad available, to check whether they are complete or not. It is important not to perform a Load on the SpoOperation again of course, as that will try to execute the operation again, which will cause errors like "Site already exists". Once the RefreshLoad method has been called, we have to call ExecuteQuery again to perform the actual refresh. While not complete we will wait for 10 seconds and loop.

function WaitForOperations($JobContext, $Operations, $Description){
        
    Write-Host "$Description executing..."
    $TotalOperations = $Operations.length
    if($TotalOperations -eq 0){
        Write-Host "Nothing to execute!"
        return
    }
    while ($true)
    {       
        $CompletedJobs = (($Operations | ? IsComplete -EQ $true) | Measure-Object).Count
        Write-Host "$Description status: $CompletedJobs of $TotalOperations completed!"
        if($CompletedJobs -eq $TotalOperations){
            Write-Host "Operation completed!" -ForegroundColor Green
            break
        }
        Sleep -Seconds 10
        $Operations | % { 
            $_.RefreshLoad() 
        }
        ExecuteLoad -ExecContext $JobContext -ReloadObject $Operations
    }
} 

Also, let's implement the ProcessError method. This method will store the error in a collection variable we will print at the end of the script execution.
First, add this to the start of the CmdLet:

$ErrorLog = @() 

Implement the ProcessError method like this:

function ProcessError($Message){
    Write-Host "$Message`n" -ForegroundColor Red
    Write-Host $Error[0].Exception.Message -ForegroundColor Red
    $ErrorLog += $Message
} 

Add the following code to the end of the cmdlet body:

if($ErrorLog.Count -ne 0){
    Write-Host "`nThe script was executed successfully, but some errors occured:" -ForegroundColor Red
    $ErrorLog | % {
        Write-Host "`n$_" -ForegroundColor Red
    }
    Write-Host 'Check the $Error variable for more information' -ForegroundColor Red
}else{
    Write-Host "`nALL DONE! Virtual pat on the shoulder and everything!`n" -ForegroundColor Magenta   
} 

Delete Sites

Before we (re)create sites, we should check if they exist already. If so, we will delete them. For this we need two steps:

  1. Delete the site
  2. Remove the site from the recycle bin – don't forget this step as creating sites at a URL is not possible when a site that was at that URL is still in the recycle bin

The CmdLet content should now look like this:

#Delete sites first if they exist!
Write-Host "`nDeleting existing sites`n" -ForegroundColor Magenta
DeleteSites -Sites $BatchSiteNames

#Recycle sites first if they exist!
Write-Host "`nRecycling deleted sites`n" -ForegroundColor Magenta
RecycleSites -Sites $BatchSiteNames

#now create the sites   
Write-Host "`nCreating the site collections`n" -ForegroundColor Magenta    
CreateSites -Sites $BatchSiteNames 

Let's implement the DeleteSites function.
In this function we will call the GetSiteProperties method to get all the existing site collections. In my opinion this is more efficient than trying to get a site collection by identity for each site collection you want to create. This depends on the number of site collections your tenant has of course.
We'll loop over the site collections and check if they are in the batch of sitenames we want to create. If so, we'll delete the site.

function DeleteSites ($Sites){
    Write-Host "Getting Sites"
    #Set Admin Context
    $Context = GetContext -Url $AdminUrl
    #Get the Tenant object
    $Tenant = New-Object Microsoft.Online.SharePoint.TenantAdministration.Tenant($Context)
    #Get all site collections
    $TenantSiteCollections=$Tenant.GetSiteProperties(0,$false) 
    $Context.Load($TenantSiteCollections) 
    ExecuteLoad -ExecContext $Context
    $Operations = @()
    $TenantSiteCollections | % {
        if($_.Url -in $Sites){
            $SiteDeleteUrl = $_.Url
            try{
                Write-Host "Site with url $SiteDeleteUrl will be deleted" -ForegroundColor Yellow
                $Operation = $Tenant.RemoveSite($SiteDeleteUrl)
                $Context.Load($Operation)
                $Operations += $Operation
            }catch{
                ProcessError("Error deleting $SiteDeleteUrl")
            }
        }
    }
    ExecuteLoad -ExecContext $Context -ReloadObject $Operations

    WaitForOperations -JobContext $Context -Operations $Operations -Description "Site deletion"

    $Context.Dispose()        
} 

And in a pretty similar way, the RecycleSites function.

function RecycleSites($Sites){
    Write-Host "Getting Deleted Sites"
    #Set Admin Context
    $Context = GetContext -Url $AdminUrl
    #Get the Tenant object
    $Tenant = New-Object Microsoft.Online.SharePoint.TenantAdministration.Tenant($Context)
    $TenantDeletedSiteCollections=$Tenant.GetDeletedSiteProperties(0) 
    $Context.Load($TenantDeletedSiteCollections) 
    ExecuteLoad -ExecContext $Context
    $Operations = @()
    $OperationsStarted = 0
    $TenantDeletedSiteCollections | % {
        if($_.Url -in $Sites){
            $SiteRecycleUrl = $_.Url
            try{
                Write-Host "Site with url $SiteRecycleUrl will be removed from the recycle bin" -ForegroundColor Yellow
                $Operation = $Tenant.RemoveDeletedSite($SiteRecycleUrl)
                $Context.Load($Operation)       
                $Operations += $Operation
            }catch{
                ProcessError("Error removing $SiteRecycleUrl from recycle bin")
            }
        }
    }
    ExecuteLoad -ExecContext $Context -ReloadObject $Operations
    WaitForOperations -JobContext $Context -Operations $Operations -Description "Recycled sites deletion"
    #dispose
    $Context.Dispose()
} 

Let's now check if we need to apply a custom template.
A template would typically be saved from a site you created on SharePoint Online that you want to reuse. You may try with solutions from on-prem or that you found on the internet, but none are guaranteed to work as some features might be missing.
Add this to your CmdLet body:

#now apply the template if needed
if($IsCustomTemplate){   
Write-Host "`nApplying Custom Template`n" -ForegroundColor Magenta    
ApplyTemplate -Sites $BatchSiteNames
} 

Let's implement the ApplyTemplate function. First we need to upload the solution file. Then we'll activate it. Next, we'll apply it to the rootweb of the site collection. After this we'll remove the uploaded file.

function ApplyTemplate($Sites){
        
    $Sites | % {
        $SiteApplyUrl = $_
        try{

            Write-Host "Uploading and applying template $SiteTemplate to $SiteApplyUrl" -ForegroundColor White

            $Context = GetContext -Url $SiteApplyUrl

            $PackageFileStream = New-Object System.IO.FileStream($PackagePath, [System.IO.FileMode]::Open) 
            
            Write-Host "Uploading template" -ForegroundColor Gray
            $SolutionGallery =  $Context.Web.Lists.GetByTitle("Solution Gallery") 
            $SolutionGalleryRootFolder = $solutionGallery.RootFolder

            $SPFileInfo = New-Object Microsoft.SharePoint.Client.FileCreationInformation 
            $SPFileInfo.Overwrite = $true 
            $SPFileInfo.ContentStream = $PackageFileStream 
            $SPFileInfo.URL = $SiteTemplate 

            $UploadedFile = $SolutionGalleryRootFolder.Files.Add($SPFileInfo)
            $Context.Load($UploadedFile)
            ExecuteLoad -ExecContext $Context

            Write-Host "Activating template" -ForegroundColor Gray

            $PackageInfo = New-Object Microsoft.SharePoint.Client.Publishing.DesignPackageInfo
            $PackageInfo.PackageName = $SiteTemplate 
            $PackageInfo.PackageGUID = [GUID]::Empty 
            $PackageInfo.MajorVersion = "1" 
            $PackageInfo.MinorVersion = "0"
            
            [Microsoft.SharePoint.Client.Publishing.DesignPackage]::Install($Context, $Context.Site, $PackageInfo, $UploadedFile.ServerRelativeUrl)
            ExecuteLoad -ExecContext $Context

            Write-Host "Applying template" -ForegroundColor Gray

            $AvailableTemplates = $Context.Site.GetWebTemplates(1033, 0)
            $Context.Load($AvailableTemplates)
            ExecuteLoad -ExecContext $Context

            $Context.RequestTimeout = 480000
            $Context.Site.RootWeb.ApplyWebTemplate($AvailableTemplates[$AvailableTemplates.Count - 1].Name)
            ExecuteLoad -ExecContext $Context

            Write-Host "Deleting temporary file" -ForegroundColor Gray
            $UploadedFile.DeleteObject()
            ExecuteLoad -ExecContext $Context

            Write-Host "`n"
        }catch{
            ProcessError("Error applying template to site $SiteApplyUrl")
        }finally{
            $PackageFileStream.Dispose()
            $Context.Dispose()
        }
    }                        
} 

As a final step we'll add the user from the $SiteOwner parameter as a site collection administrator.
Add this to the CmdLet body:

Write-Host "`nSetting Security`n" -ForegroundColor Magenta    
SetSecurity -Sites $BatchSiteNames 

Let's implement the SetSecurity function:

function SetSecurity ($Sites){
    $Sites | % {
        try{
            Write-Host "Adding site collection admin $SiteOwner to $_" -ForegroundColor White

            $Context = GetContext -Url $_

            $User = $Context.Web.EnsureUser($SiteOwner)
	        $Context.Load($User)
	        ExecuteLoad -ExecContext $Context

	        $User.IsSiteAdmin = $true
	        $User.Update()
	        ExecuteLoad -ExecContext $Context

                
        }catch{
            ProcessError("Error applying security to site $_")
        }finally{
            $Context.Dispose()
        }
    }
} 

That's it! We should be good to go and create endless batches of SharePoint Online site collections!

Have fun ;)


You can download the script, it is attached to this post...

 

 

 

 

 

 

U2U.CreateOnlineSiteCollections.ps1 (15,4KB)

An Office 365 App for Site Collection Provisioning in SharePoint Online

Continuing on my previous post, I decided to create a new Office 365 app that would be able to manage site collections in SharePoint Online.

When deciding to build an app that does site collection provisioning, you could chose to do this as a SharePoint App, but SharePoint Apps are typically contextual to the web they're hosted in. With the introduction of Office 365 Apps, which are contextual to the Office 365 tenant, provisioning site collections from an app seems more to be a task for an Office 365 App.

My new application should be capable of:

  • Getting an overview of available site collections
  • Creating a new site collection
  • Delete an existing site collection

My previous post proved that you can access the SharePoint Online sites by means of the Client Side Object Model (CSOM). My goal now is to prove that you can also do SharePoint online administration tasks from an Office 365 app, using also CSOM.

If you’re interested in the full source code, you can get it from GitHub. There you’ll also find how you can register the app in Azure Active Directory.

Accessing SharePoint Online Administration

As an extension to the SharePoint Client Side Object Model, you have the “Microsoft.Online.SharePoint.Client.Tenant.dll” library which gives you access to SharePoint Online Administration tasks. I.e. creating/deleting site collections, managing deleted site collections, … .

Now, would it then be possible to use the Azure application registration and authentication to i.e. manage site collections? Yes, it is possible!
In my previous post, I mentioned how you obtain the access token from Azure Active Directory. You can then get an accessToken for SharePoint Online administration as follows:

string clientID = ConfigurationManager.AppSettings["ida:ClientId"] ?? ConfigurationManager.AppSettings["ida:ClientID"];
string appKey = ConfigurationManager.AppSettings["ida:AppKey"] ?? ConfigurationManager.AppSettings["ida:Password"];
string spSiteUrl = "https://tenant-admin.sharepoint.com";

string accessToken = await GetAccessToken(clientID, appKey, spSiteUrl)

Take care that you replace tenant by your tenant name. Now that you have the accesToken, you can create the ClientContext as follows:

ClientContext clientContext = new ClientContext("https://tenant-admin.sharepoint.com");
clientContext.ExecutingWebRequest +=
    (sender, e) =>
    {
        e.WebRequestExecutor.WebRequest.Headers["Authorization"] = "Bearer " + accessToken;
    };

Getting an overview of all the site collections

Now that you have the ClientContext, you can start using the Tenant class to get an overview of the available site collections. You can do this as follows:

// Create tenant
Tenant tenant = new Tenant(ctx);

// Get the site properties
SPOSitePropertiesEnumerable allSiteProperties = tenant.GetSiteProperties(0, true);

// Load
ctx.Load(allSiteProperties);
await ctx.ExecuteQueryAsync();

The GetSiteProperties method currently only returns a maximum of 300 site collections. If you would like to get the next 300, increment the startIndex parameter to 300.

Creating a new site collection

In order to create a new site collection, you need to know the code of the webtemplate that should be applied to the new site collection. Now if you know the template code by hart, you can just pass it as a string, but better is to first check the available templates in SharePoint Online and chose the one you want to use. Because you have a ClientContext to your SharePoint Online Administration, you can check the web property for available web templates. You can do this as follows:

// Get the webtemplates
WebTemplateCollection webTemplates = ctx.Web.GetAvailableWebTemplates(lcid, false);

// Load
ctx.Load(webTemplates, templates => templates.Where(t => t.IsHidden == false));

// Execute
await ctx.ExecuteQueryAsync();

The GetAvailableWebTemplates method takes in the language code lcid. Here also, if you know the language code that is available by hart, you can just pass it in here. Another approach would be to first check which languages are available in SharePoint Online:

// Load
ctx.Load(ctx.Web, w => w.SupportedUILanguageIds);

// Execute query
await ctx.ExecuteQueryAsync();

Now that we have the ClientContext, Language and template; you can create a new site collection. The Tenant class provides a method called CreateSite that accepts a SiteCreationProperties object as input and creates a new site collection using the properties supplied in the SiteCreationProperties instance.

The snippet below illustrates how you can create a new site collection. In this snippet, you might observe that the CreateSite method returns an SpoOperation. Now, any operation that we can do on a site collection will return an SpoOperation. So when we create a new site collection, it will return an SpoOperation containing the information about the site collection creation operation. You can check the IsComplete property on the SpoOperation to check whether the operation has completed or not (reload the SpoOperation to check again), this is because the creation of a site collection happens asynchronously.

// Create tenant
Tenant tenant = new Tenant(ctx);

// Create properties
SiteCreationProperties siteCreationProperties =
    new SiteCreationProperties()
    {
        Url = "https://tenant.sharepoint.com/sites/createdbycode"
        ,
        Title = "Site created by code"
        ,
        Owner = "admin@tenant.onmicrosoft.com"
        ,
        Template = "STS#0"
        ,
        StorageMaximumLevel = 100
        ,
        UserCodeMaximumLevel = 50
    };

// Create the sitecollection
SpoOperation operation = tenant.CreateSite(siteCreationProperties);

// Execute query
ctx.ExecuteQuery();

Deleting an existing site collection

In order to delete an existing site collection, the basic steps are again the same. The difference is the method that will be called from the Tenant class, being RemoveSite. Do note that the site collection is not permanently deleted, but is pushed to the recycle bin in which it can still be restored. Note that the RemoveSite also returns an SpoOperation.

// Create tenant
Tenant tenant = new Tenant(ctx);

// Perform delete
SpoOperation spoOperation = tenant.RemoveSite("https://tenant.sharepoint.com/sites/createdbycode");

// Load and execute
ctx.Load(spoOperation);
await ctx.ExecuteQueryAsync();

App screenshots

Overview of sites:

05

Creating a new site:

06

TextBox